All of lore.kernel.org
 help / color / mirror / Atom feed
* [PATCH v2 0/6] automation: add QTB test framework for riscv64 smoke tests
@ 2026-08-27  9:35 Baptiste Le Duc
  2026-08-27  9:42 ` [PATCH v2 1/6] Add a QTB container to run the Xen riscv64 tests Baptiste Le Duc
                   ` (5 more replies)
  0 siblings, 6 replies; 9+ messages in thread
From: Baptiste Le Duc @ 2026-08-27  9:35 UTC (permalink / raw)
  To: baptiste.leduc38, xen-devel
  Cc: Baptiste Le Duc, Andrew Cooper, Anthony PERARD, Michal Orzel,
	Jan Beulich, Julien Grall, Roger Pau Monné,
	Stefano Stabellini, Doug Goldstein

v1 was posted on 2026-08-10 with no response yet (13 business days). This
v2 also fixes a bug flagged during internal review, see "Changes since v1"
below.

Xen is being made safety certifiable to IEC 61508 SIL 3 and ISO 26262 ASIL
D, with Arm and x86 as the current targets [1]. Evidence at those levels is
requirements based testing plus structural coverage, produced automatically
and repeatably in CI.

QTB (QEMU Test Bench) [2] is the framework AMD wrote for it as part of the
Xen safety initiative. It drives a live QEMU instance over qtest, QMP and
GDB, so a test has full access to the machine while emulation runs: read
and write the consoles, inspect registers and memory, inject interrupts and
faults, all from Python and all reproducible in a pipeline. QTB is not
upstream in QEMU yet, but is planned to be.

RISC-V is not in the certification scope today. It is also the youngest
port, so it has close to no test infrastructure to undo, which makes it the
cheapest place to adopt QTB. Starting the riscv64 tests on the framework
now means the port grows its tests in the shape certification asks for as
the port itself grows, instead of a pile of expect scripts to convert later
if riscv64 ever becomes a certification target. It also puts a second
architecture on QTB, which is useful to the framework itself before it is
proposed to QEMU upstream.

Concretely, the riscv64 CI coverage today is one expect script,
automation/scripts/qemu-smoke-riscv64.sh, which boots Xen alone under QEMU
and greps a single string out of one serial console. The machine it boots
is hardcoded, so a second configuration means a second script, and a test
with a DomU in it means growing guest handling from scratch.

This series replaces that script with a data-driven framework using QTB. A
machine is a YAML entry (pcpus, scheduler, interrupt controller, boot
arguments), a test type is a small Python class saying what to do with a
booted machine, and a test binds a machine to that type's expectations.
Adding a configuration to CI is then a config change and a job stanza.

Patch 1 goes to test-artifacts [3] and must land first, since patches 2-6
run inside the container it adds. Patches 2-6 go to xen.git.

test-artifacts [3]:
- Add a QTB container to run the Xen riscv64 tests
    debian:13-qtb-riscv64, carrying qemu.qtb from AMD's QEMU fork and the
    dtc/fdt dependencies the framework needs at run time.

xen.git:
- automation/qtb: add jinja2 device trees for riscv64 smoke tests
    The host tree varies with hart count, MMU type and device set, so it is
    rendered per machine from a template rather than shipping one static
    .dts per configuration.
- automation/qtb: add Python QTB framework with the console-test type
    The base layer every test type builds on (machine catalog lookup, host
    DT generation, QEMU invocation, per-console log capture) plus the first
    test type, which asserts every string listed in console-test.yaml is
    printed on the expected console.
- automation/qtb: add unit tests for the QTB framework
    pytest coverage of the QEMU-agnostic parts, for developers only.
- automation/qtb: add QTB framework README
    How to add a machine, add a test type and run one locally.
- CI: run the riscv64 smoke test via QTB framework console-test
    Repoints qemu-smoke-riscv64-gcc at the framework and drops
    qemu-smoke-riscv64.sh, which then has no caller left.

Testing: Unit tests + QEMU only, as the existing riscv64 CI does.
qemu-smoke-riscv64-gcc runs the same check as before.

The catalog ships a single Xen-only machine, since Xen cannot boot a DomU
on RISC-V yet. DomU machines plus an irq-test type using QTest interrupt
injection follow once DomU support lands.

CI pipeline:
https://gitlab.com/xen-project/people/baptleduc/xen/-/pipelines/2795907042

[1] https://elisa.tech/blog/2026/07/22/the-final-phase-of-xen-safety-solving-coverage-and-residual-gaps-stefano-stabellini-amd/
[2] https://gitlab.com/xen-project/people/amd/qemu/-/tree/safety
[3] https://gitlab.com/xen-project/hardware/test-artifacts
[4] https://lore.kernel.org/xen-devel/1786980254.8631fc262581453bbf619ec5b2062170.1a01052c27d000c4f3@vates.tech/

---
Changes since v1:
- Resolve qemu-system-riscv64 from $PATH instead of pinning qemu-9.0.0 in
  test.yaml, dropping the now-unneeded OpenSBI entry too: the
  13-qtb-riscv64 container already bundles both (flagged by Zheng Zhang
  during internal review [4]). Also drops the qemu-9.0.0-riscv64
  test-artifacts dependency noted above.

Baptiste Le Duc (5):
  automation/qtb: add jinja2 device trees for riscv64 smoke tests
  automation/qtb: add Python QTB framework with the console-test type
  automation/qtb: add unit tests for the QTB framework
  automation/qtb: add QTB framework README
  CI: run the riscv64 smoke test via QTB framework console-test

 .gitlab-ci.yml                                |   3 +
 automation/gitlab-ci/test.yaml                |  20 +-
 automation/scripts/qemu-smoke-riscv64.sh      |  19 --
 automation/scripts/qemu_smoke_riscv64.py      | 122 ++++++++++
 automation/scripts/qtb/__init__.py            |   2 +
 automation/scripts/qtb/riscv/README.md        | 182 +++++++++++++++
 automation/scripts/qtb/riscv/__init__.py      |   9 +
 automation/scripts/qtb/riscv/config.py        | 119 ++++++++++
 automation/scripts/qtb/riscv/config.yaml      |  17 ++
 .../qtb/riscv/console_test/__init__.py        |   4 +
 .../qtb/riscv/console_test/console-test.yaml  |  18 ++
 .../qtb/riscv/console_test/console_test.py    | 145 ++++++++++++
 automation/scripts/qtb/riscv/dt.py            |  57 +++++
 .../scripts/qtb/riscv/dts/qemu-host.dts.j2    | 160 +++++++++++++
 automation/scripts/qtb/riscv/machine.py       |  57 +++++
 automation/scripts/qtb/riscv/paths.py         |  60 +++++
 automation/scripts/qtb/riscv/qtb_test.py      |  53 +++++
 automation/scripts/qtb/riscv/unit/__init__.py |   2 +
 automation/scripts/qtb/riscv/unit/conftest.py |  42 ++++
 .../scripts/qtb/riscv/unit/test_config.py     | 121 ++++++++++
 .../qtb/riscv/unit/test_console_test.py       | 217 ++++++++++++++++++
 automation/scripts/qtb/riscv/unit/test_dt.py  |  99 ++++++++
 .../scripts/qtb/riscv/unit/test_machine.py    |  60 +++++
 .../scripts/qtb/riscv/unit/test_temp_dir.py   |  42 ++++
 .../scripts/qtb/riscv/unit/test_xen_dt.py     |  46 ++++
 automation/scripts/qtb/riscv/xen_dt.py        |  58 +++++
 26 files changed, 1709 insertions(+), 25 deletions(-)
 delete mode 100755 automation/scripts/qemu-smoke-riscv64.sh
 create mode 100755 automation/scripts/qemu_smoke_riscv64.py
 create mode 100644 automation/scripts/qtb/__init__.py
 create mode 100644 automation/scripts/qtb/riscv/README.md
 create mode 100644 automation/scripts/qtb/riscv/__init__.py
 create mode 100644 automation/scripts/qtb/riscv/config.py
 create mode 100644 automation/scripts/qtb/riscv/config.yaml
 create mode 100644 automation/scripts/qtb/riscv/console_test/__init__.py
 create mode 100644 automation/scripts/qtb/riscv/console_test/console-test.yaml
 create mode 100644 automation/scripts/qtb/riscv/console_test/console_test.py
 create mode 100644 automation/scripts/qtb/riscv/dt.py
 create mode 100644 automation/scripts/qtb/riscv/dts/qemu-host.dts.j2
 create mode 100644 automation/scripts/qtb/riscv/machine.py
 create mode 100644 automation/scripts/qtb/riscv/paths.py
 create mode 100644 automation/scripts/qtb/riscv/qtb_test.py
 create mode 100644 automation/scripts/qtb/riscv/unit/__init__.py
 create mode 100644 automation/scripts/qtb/riscv/unit/conftest.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_config.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_console_test.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_dt.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_machine.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_temp_dir.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_xen_dt.py
 create mode 100644 automation/scripts/qtb/riscv/xen_dt.py



^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH v2 1/6] Add a QTB container to run the Xen riscv64 tests
  2026-08-27  9:35 [PATCH v2 0/6] automation: add QTB test framework for riscv64 smoke tests Baptiste Le Duc
@ 2026-08-27  9:42 ` Baptiste Le Duc
  2026-08-27  9:42 ` [PATCH v2 2/6] automation/qtb: add jinja2 device trees for riscv64 smoke tests Baptiste Le Duc
                   ` (4 subsequent siblings)
  5 siblings, 0 replies; 9+ messages in thread
From: Baptiste Le Duc @ 2026-08-27  9:42 UTC (permalink / raw)
  To: xen-devel
  Cc: Baptiste Le Duc, Andrew Cooper, Anthony PERARD, Michal Orzel,
	Jan Beulich, Julien Grall, Roger Pau Monné,
	Stefano Stabellini

QTB (QEMU Test Bench) is a Python framework, developed as part of AMD's Xen
safety initiative, that drives a live QEMU instance over qtest and QMP. It
gives a test full access to the machine while the emulation runs: read and
write the consoles, inspect the registers and the memory, inject interrupts.

For now the riscv64 CI tests only use it to read the Xen console in the
smoke test, since DomU is not supported yet. Once it is, the same
framework will read and write the DomU consoles and inject IRQs, so the
tests can assert an interrupt reaches the expected guest and vCPU.

QTB is not upstream in QEMU yet, though it is planned to be, so get the
framework from AMD's fork gitlab.com/xen-project/people/amd/qemu and
pip-installs qemu.qtb from it.

Signed-off-by: Baptiste Le Duc <baptiste.le-duc@vates.tech>
---
 containerize                            |  1 +
 images/debian/13-qtb-riscv64.dockerfile | 45 +++++++++++++++++++++++++
 2 files changed, 46 insertions(+)
 create mode 100644 images/debian/13-qtb-riscv64.dockerfile

diff --git a/containerize b/containerize
index dad9afa..477b670 100755
--- a/containerize
+++ b/containerize
@@ -31,6 +31,7 @@ case "_${CONTAINER}" in
     _alpine-3.24-arm64-base) CONTAINER="${BASE}/alpine:3.24-arm64-base" ;;
     _alpine-3.24-arm64-build) CONTAINER="${BASE}/alpine:3.24-arm64-build" ;;
     _alpine-3.24-x86_64-base) CONTAINER="${BASE}/alpine:3.24-x86_64-base" ;;
+    _debian-13-qtb-riscv64) CONTAINER="${BASE}/debian:13-qtb-riscv64" ;;
     _alpine-3.24-x86_64-build|_) CONTAINER="${BASE}/alpine:3.24-x86_64-build" ;;
 esac
 
diff --git a/images/debian/13-qtb-riscv64.dockerfile b/images/debian/13-qtb-riscv64.dockerfile
new file mode 100644
index 0000000..86215d3
--- /dev/null
+++ b/images/debian/13-qtb-riscv64.dockerfile
@@ -0,0 +1,45 @@
+# syntax=docker/dockerfile:1
+FROM --platform=linux/amd64 debian:trixie-slim
+LABEL maintainer.name="The Xen Project"
+LABEL maintainer.email="xen-devel@lists.xenproject.org"
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+ARG QEMU_REPO=https://gitlab.com/xen-project/people/amd/qemu.git
+ARG QEMU_COMMIT=24cd7e5c0d7e4db8e651f091d0b9da2e7c58dbab
+
+RUN <<EOF
+#!/bin/bash
+    set -eu
+
+    useradd --create-home user
+
+    apt-get update
+
+    DEPS=(# Base environment
+        ca-certificates
+        device-tree-compiler
+        git
+        python3-jinja2
+        python3-minimal
+        python3-pip
+        python3-yaml
+
+        # Qemu for test phase
+        qemu-system-riscv64
+    )
+
+    apt-get -y --no-install-recommends install "${DEPS[@]}"
+
+    # QTB framework
+    git init /tmp/qemu
+    git -C /tmp/qemu fetch --depth 1 "$QEMU_REPO" "$QEMU_COMMIT"
+    git -C /tmp/qemu checkout FETCH_HEAD
+    pip install --break-system-packages /tmp/qemu/python[qtb]
+
+    rm -rf /tmp/qemu
+    rm -rf /var/lib/apt/lists/*
+EOF
+
+USER user
+WORKDIR /build
-- 
2.55.0



^ permalink raw reply related	[flat|nested] 9+ messages in thread

* [PATCH v2 2/6] automation/qtb: add jinja2 device trees for riscv64 smoke tests
  2026-08-27  9:35 [PATCH v2 0/6] automation: add QTB test framework for riscv64 smoke tests Baptiste Le Duc
  2026-08-27  9:42 ` [PATCH v2 1/6] Add a QTB container to run the Xen riscv64 tests Baptiste Le Duc
@ 2026-08-27  9:42 ` Baptiste Le Duc
  2026-09-10 10:00   ` Zhang Zheng
  2026-08-27  9:42 ` [PATCH v2 3/6] automation/qtb: add Python QTB framework with the console-test type Baptiste Le Duc
                   ` (3 subsequent siblings)
  5 siblings, 1 reply; 9+ messages in thread
From: Baptiste Le Duc @ 2026-08-27  9:42 UTC (permalink / raw)
  To: xen-devel; +Cc: Baptiste Le Duc, Doug Goldstein, Stefano Stabellini

The dom0less RISC-V smoke tests need a host device tree describing the
platform (CPUs, APLIC/IMSIC, uart). It varies per machine (hart count, MMU
type), so a single static .dts cannot cover the test matrix.

Add dts/qemu-host.dts.j2, a template of the QEMU virt platform in
aia=aplic-imsic mode: per-hart cpu/cpu-intc nodes, the M- and S-mode APLIC
and IMSIC pairs, CLINT and the ns16550a uart. It takes ncpus, mmu_type and
xen_bootargs as arguments.

Values QEMU hardcodes are set as named constants matching their source
symbols (QEMU_UART0_IRQ, QEMU_IRQCHIP_NUM_SOURCES, ...) rather than
open-coded, so a QEMU-side change is easy to trace.

The template is inert on its own: the generated dtb will be used in next
patch.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Baptiste Le Duc <baptiste.le-duc@vates.tech>
---
 .../scripts/qtb/riscv/dts/qemu-host.dts.j2    | 160 ++++++++++++++++++
 1 file changed, 160 insertions(+)
 create mode 100644 automation/scripts/qtb/riscv/dts/qemu-host.dts.j2

diff --git a/automation/scripts/qtb/riscv/dts/qemu-host.dts.j2 b/automation/scripts/qtb/riscv/dts/qemu-host.dts.j2
new file mode 100644
index 0000000000..13a8e983ce
--- /dev/null
+++ b/automation/scripts/qtb/riscv/dts/qemu-host.dts.j2
@@ -0,0 +1,160 @@
+/dts-v1/;
+
+{#-
+ * Jinja2 QEMU "virt" platform device tree for Xen RISC-V tests.
+ *
+ * Interrupt controller: APLIC in MSI mode + IMSIC
+ * (QEMU -M virt,aia=aplic-imsic).
+ *
+ * Rendered by xen_dt.py.
+ *
+ * Variables:
+ *   ncpus        - number of physical harts                (int, >= 1)
+ *   mmu_type     - Xen host MMU type, e.g. "sv39"          (string)
+ *   xen_bootargs - Xen command line                        (string)
+ *
+ * Per-hart nodes are labelled cpu<i> / cpu<i>_intc and referenced with &label.
+ *
+ * No `aia-guests=N`, so no VS-mode guest files: IMSIC reg size is
+ * ncpus * page size.
+-#}
+{#- Values QEMU hardcodes, need to be described to Xen -#}
+{%- set QEMU_TIMEBASE_FREQUENCY = 10000000 %}   {#- RISCV_ACLINT_DEFAULT_TIMEBASE_FREQ -#}
+{%- set QEMU_IRQCHIP_NUM_SOURCES = 96 %}        {#- VIRT_IRQCHIP_NUM_SOURCES (virt.h) -#}
+{%- set QEMU_IRQCHIP_NUM_MSIS = 255 %}          {#- VIRT_IRQCHIP_NUM_MSIS -#}
+{%- set QEMU_UART_CLOCK_FREQUENCY = 3686400 %}  {#- create_fdt_uart() -#}
+{%- set QEMU_UART0_IRQ = 10 %}                  {#- UART0_IRQ -#}
+{%- set QEMU_IMSIC_PAGE_SZ = 0x1000 %}          {#- IMSIC_MMIO_PAGE_SZ -#}
+
+{%- set IRQ_TYPE_LEVEL_HIGH = 4 %}
+{%- set APLIC_IRQ_CELLS = 2 %}
+
+{%- set IRQ_M_SOFT = 3 %}
+{%- set IRQ_M_TIMER = 7 %}
+{%- set IRQ_S_EXT = 9 %}
+{%- set IRQ_M_EXT = 11 %}
+
+/ {
+    #address-cells = <0x02>;
+    #size-cells = <0x02>;
+    compatible = "riscv-virtio";
+    model = "riscv-virtio,qemu";
+
+    memory@80000000 {
+        device_type = "memory";
+        reg = <0x00 0x80000000 0x00 0x80000000>;
+    };
+
+    cpus {
+        #address-cells = <0x01>;
+        #size-cells = <0x00>;
+        timebase-frequency = <{{ QEMU_TIMEBASE_FREQUENCY }}>;
+{% for i in range(ncpus) %}
+        cpu{{ i }}: cpu@{{ i }} {
+            device_type = "cpu";
+            reg = <0x{{ '%x' % i }}>;
+            status = "okay";
+            compatible = "riscv";
+            riscv,cbop-block-size = <0x40>;
+            riscv,cboz-block-size = <0x40>;
+            riscv,cbom-block-size = <0x40>;
+            riscv,isa = "rv64imafdch_zicntr_zicsr_zifencei_zihintpause_zihpm_zba_zbb_zbs_smstateen_svpbmt_smaia_ssaia";
+            mmu-type = "riscv,{{ mmu_type }}";
+
+            cpu{{ i }}_intc: interrupt-controller@{{ i }} {
+                #interrupt-cells = <0x01>;
+                interrupt-controller;
+                compatible = "riscv,cpu-intc";
+            };
+        };
+{% endfor %}
+        cpu-map {
+
+            cluster0 {
+{% for i in range(ncpus) %}
+                core{{ i }} {
+                    cpu = <&cpu{{ i }}>;
+                };
+{% endfor %}
+            };
+        };
+    };
+
+    soc {
+        #address-cells = <0x02>;
+        #size-cells = <0x02>;
+        compatible = "simple-bus";
+        ranges;
+
+        serial@10000000 {
+            interrupts = <{{ QEMU_UART0_IRQ }} {{ IRQ_TYPE_LEVEL_HIGH }}>;
+            interrupt-parent = <&aplic_s>;
+            clock-frequency = <{{ QEMU_UART_CLOCK_FREQUENCY }}>;
+            reg = <0x00 0x10000000 0x00 0x100>;
+            compatible = "ns16550a";
+        };
+
+        aplic_s: aplic@d000000 {
+            riscv,num-sources = <{{ QEMU_IRQCHIP_NUM_SOURCES }}>;
+            reg = <0x00 0xd000000 0x00 0x8000>;
+            msi-parent = <&imsic_s>;
+            interrupt-controller;
+            #interrupt-cells = <{{ APLIC_IRQ_CELLS }}>;
+            compatible = "riscv,aplic";
+        };
+
+        aplic@c000000 {
+            riscv,delegate = <&aplic_s 0x01 {{ QEMU_IRQCHIP_NUM_SOURCES }}>;
+            riscv,children = <&aplic_s>;
+            riscv,num-sources = <{{ QEMU_IRQCHIP_NUM_SOURCES }}>;
+            reg = <0x00 0xc000000 0x00 0x8000>;
+            msi-parent = <&imsic_m>;
+            interrupt-controller;
+            #interrupt-cells = <{{ APLIC_IRQ_CELLS }}>;
+            compatible = "riscv,aplic";
+        };
+
+        imsic_s: imsics@28000000 {
+            riscv,num-ids = <{{ QEMU_IRQCHIP_NUM_MSIS }}>;
+            reg = <0x00 0x28000000 0x00 0x{{ '%x' % (ncpus * QEMU_IMSIC_PAGE_SZ) }}>;
+            interrupts-extended = <
+                {%- for i in range(ncpus) %}
+                    &cpu{{ i }}_intc {{ IRQ_S_EXT }}
+                {%- endfor %}
+            >;
+            msi-controller;
+            interrupt-controller;
+            #interrupt-cells = <0x00>;
+            compatible = "riscv,imsics";
+        };
+
+        imsic_m: imsics@24000000 {
+            riscv,num-ids = <{{ QEMU_IRQCHIP_NUM_MSIS }}>;
+            reg = <0x00 0x24000000 0x00 0x{{ '%x' % (ncpus * QEMU_IMSIC_PAGE_SZ) }}>;
+            interrupts-extended = <
+                {%- for i in range(ncpus) %}
+                    &cpu{{ i }}_intc {{ IRQ_M_EXT }}
+                {%- endfor %}
+            >;
+            msi-controller;
+            interrupt-controller;
+            #interrupt-cells = <0x00>;
+            compatible = "riscv,imsics";
+        };
+
+        clint@2000000 {
+            interrupts-extended = <
+                {%- for i in range(ncpus) %}
+                    &cpu{{ i }}_intc {{ IRQ_M_SOFT }} &cpu{{ i }}_intc {{ IRQ_M_TIMER }}
+                {%- endfor %}
+            >;
+            reg = <0x00 0x2000000 0x00 0x10000>;
+            compatible = "sifive,clint0", "riscv,clint0";
+        };
+    };
+
+    chosen {
+        stdout-path = "/soc/serial@10000000";
+        xen,xen-bootargs = "{{ xen_bootargs }}";
+    };
+};


^ permalink raw reply related	[flat|nested] 9+ messages in thread

* [PATCH v2 3/6] automation/qtb: add Python QTB framework with the console-test type
  2026-08-27  9:35 [PATCH v2 0/6] automation: add QTB test framework for riscv64 smoke tests Baptiste Le Duc
  2026-08-27  9:42 ` [PATCH v2 1/6] Add a QTB container to run the Xen riscv64 tests Baptiste Le Duc
  2026-08-27  9:42 ` [PATCH v2 2/6] automation/qtb: add jinja2 device trees for riscv64 smoke tests Baptiste Le Duc
@ 2026-08-27  9:42 ` Baptiste Le Duc
  2026-08-27  9:42 ` [PATCH v2 4/6] automation/qtb: add unit tests for the QTB framework Baptiste Le Duc
                   ` (2 subsequent siblings)
  5 siblings, 0 replies; 9+ messages in thread
From: Baptiste Le Duc @ 2026-08-27  9:42 UTC (permalink / raw)
  To: xen-devel; +Cc: Baptiste Le Duc, Doug Goldstein, Stefano Stabellini

Port qemu-smoke-riscv64.sh from shell to a Python QTB framework that drives
QEMU over qtest/QMP and the console to run riscv64 smoke tests.

The framework is based on AMD's QTB (QEMU Test Bench) framework, which is
not yet upstream in QEMU but is planned to be soon. This is a riscv64
adaptation of it.

The framework:
  - parses a test config (config.yaml) into typed machine descriptions
    (config.py). The host device tree of a machine is compiled on first
    use of MachineConfig.dt, so a run that never boots (`list`, or a
    config error) does not invoke dtc.
  - generates the Xen host device tree from a Jinja2 template and compiles
    it to a DTB with dtc (xen_dt.py, dt.py).
  - assembles the QEMU command line in RiscvTestMachine (machine.py),
    resolving artifact paths via paths.py.
  - defines an abstract RiscvQtbTest base shared by every test type
    (qtb_test.py).
  - wires it together behind a CLI: the test type is a leading positional
    with `list` and `run` subcommands (qemu_smoke_riscv64.py).

console-test test type comes with it. It boots a machine from the shared
catalog and asserts every expected string is printed on Xen's own console
within the timeout. Its tests live in console-test.yaml, which maps console
indices to the expected output strings. This makes it straightforward to
add support for a domU console index once Xen provides it.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Baptiste Le Duc <baptiste.le-duc@vates.tech>
---
 automation/scripts/qemu_smoke_riscv64.py      | 122 +++++++++++++++
 automation/scripts/qtb/__init__.py            |   2 +
 automation/scripts/qtb/riscv/__init__.py      |   9 ++
 automation/scripts/qtb/riscv/config.py        | 119 ++++++++++++++
 automation/scripts/qtb/riscv/config.yaml      |  17 ++
 .../qtb/riscv/console_test/__init__.py        |   4 +
 .../qtb/riscv/console_test/console-test.yaml  |  18 +++
 .../qtb/riscv/console_test/console_test.py    | 145 ++++++++++++++++++
 automation/scripts/qtb/riscv/dt.py            |  57 +++++++
 automation/scripts/qtb/riscv/machine.py       |  57 +++++++
 automation/scripts/qtb/riscv/paths.py         |  60 ++++++++
 automation/scripts/qtb/riscv/qtb_test.py      |  53 +++++++
 automation/scripts/qtb/riscv/xen_dt.py        |  58 +++++++
 13 files changed, 721 insertions(+)
 create mode 100755 automation/scripts/qemu_smoke_riscv64.py
 create mode 100644 automation/scripts/qtb/__init__.py
 create mode 100644 automation/scripts/qtb/riscv/__init__.py
 create mode 100644 automation/scripts/qtb/riscv/config.py
 create mode 100644 automation/scripts/qtb/riscv/config.yaml
 create mode 100644 automation/scripts/qtb/riscv/console_test/__init__.py
 create mode 100644 automation/scripts/qtb/riscv/console_test/console-test.yaml
 create mode 100644 automation/scripts/qtb/riscv/console_test/console_test.py
 create mode 100644 automation/scripts/qtb/riscv/dt.py
 create mode 100644 automation/scripts/qtb/riscv/machine.py
 create mode 100644 automation/scripts/qtb/riscv/paths.py
 create mode 100644 automation/scripts/qtb/riscv/qtb_test.py
 create mode 100644 automation/scripts/qtb/riscv/xen_dt.py

diff --git a/automation/scripts/qemu_smoke_riscv64.py b/automation/scripts/qemu_smoke_riscv64.py
new file mode 100755
index 0000000000..f338fafcc2
--- /dev/null
+++ b/automation/scripts/qemu_smoke_riscv64.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only
+"""CLI launcher for the qtb riscv64 dom0less tests.
+
+The test type comes first (e.g. `console-test`), then a command. Each type
+reads its own config file, which ships with the type.
+
+Commands:
+    list  Print every test the type defines in the config, then exit.
+    run   Boot one test's machine under QEMU and drive it to a pass/fail
+          verdict.
+
+Usage:
+    ./qemu_smoke_riscv64.py console-test list
+    ./qemu_smoke_riscv64.py console-test run dom0less-1smp-0domu-1vcpu-aplic-imsic-null
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import sys
+from collections.abc import Sequence
+from traceback import extract_tb, format_exc
+
+from qtb.riscv import RiscvQtbTest, TEST_TYPES, RiscvTestMachine, cleanup_temp_dir
+
+logger = logging.getLogger(__name__)
+
+
+def _run_test(test: RiscvQtbTest, log_dir: str | None) -> int:
+    """Compile the machine's device trees, boot it, and run the test."""
+    vm = RiscvTestMachine(test.machine, timeout=test.timeout, log_dir=log_dir)
+    try:
+        with vm:
+            vm.launch()
+            test.run(vm)
+    except Exception as exc:
+        print(
+            f"FAIL: {test.name}: {type(exc).__name__}: {exc}",
+            file=sys.stderr,
+            flush=True,
+        )
+        return 1
+
+    print(f"PASS: {test.name}", flush=True)
+    return 0
+
+
+def _cmd_list(ns: argparse.Namespace) -> int:
+    """`<type> list`: print every test the type defines."""
+    for name in ns.cls.list_tests(ns.cls.config_file):
+        print(name)
+    return 0
+
+
+def _cmd_run(ns: argparse.Namespace) -> int:
+    """`<type> run`: build the named test and drive it to a verdict."""
+    test = ns.cls.from_config(ns.cls.config_file, ns.test)
+    return _run_test(test, ns.log_dir)
+
+
+def setup_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        description="Launch a qtb riscv64 dom0less test: "
+        "qemu_smoke_riscv64.py <type> <command>.",
+    )
+    common_args = argparse.ArgumentParser(add_help=False)
+    common_args.add_argument(
+        "-v", "--verbose", action="store_true", help="Print debug output"
+    )
+    run_args = argparse.ArgumentParser(add_help=False)
+    run_args.add_argument("test", help="Name of the test to run.")
+    run_args.add_argument(
+        "--log-dir",
+        default=None,
+        metavar="DIR",
+        help="Directory for all logs (QEMU process log, qtest, and the "
+        "consoles as con<N>.log). When unset, no logs are written.",
+    )
+
+    # qemu_smoke_riscv64.py <type> <command>
+    types = parser.add_subparsers(dest="type", required=True)
+    for cls in TEST_TYPES:
+        desc = cls.description
+        cmds = types.add_parser(
+            cls.type_id, help=desc, description=desc
+        ).add_subparsers(dest="command", required=True)
+        cmds.add_parser(
+            "list",
+            parents=[common_args],
+            description=desc,
+            help="List the tests for the type and exit.",
+        ).set_defaults(func=_cmd_list, cls=cls)
+        cmds.add_parser(
+            "run",
+            parents=[common_args, run_args],
+            description=desc,
+            help="Run one test.",
+        ).set_defaults(func=_cmd_run, cls=cls)
+
+    return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+    ns = setup_parser().parse_args(argv)
+    logging.basicConfig(
+        level=logging.DEBUG if ns.verbose else logging.WARNING, format="%(message)s"
+    )
+    try:
+        return ns.func(ns)
+    except Exception as exc:
+        logger.debug(format_exc())
+        frame = extract_tb(exc.__traceback__)[-1]
+        print(f"{frame.filename}:{frame.lineno}: {exc}")
+        return 2
+    finally:
+        cleanup_temp_dir()
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/automation/scripts/qtb/__init__.py b/automation/scripts/qtb/__init__.py
new file mode 100644
index 0000000000..a0e6e76cb2
--- /dev/null
+++ b/automation/scripts/qtb/__init__.py
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""QTB (QEMU Test Bench) test frameworks."""
diff --git a/automation/scripts/qtb/riscv/__init__.py b/automation/scripts/qtb/riscv/__init__.py
new file mode 100644
index 0000000000..6a6c48be32
--- /dev/null
+++ b/automation/scripts/qtb/riscv/__init__.py
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""QTB riscv64 test framework package."""
+
+from .qtb_test import RiscvQtbTest
+from .console_test import ConsoleTest
+from .machine import RiscvTestMachine
+from .paths import cleanup_temp_dir
+
+TEST_TYPES: tuple[type[RiscvQtbTest], ...] = (ConsoleTest,)
diff --git a/automation/scripts/qtb/riscv/config.py b/automation/scripts/qtb/riscv/config.py
new file mode 100644
index 0000000000..b5068d6bfb
--- /dev/null
+++ b/automation/scripts/qtb/riscv/config.py
@@ -0,0 +1,119 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""YAML config parser for qtb-based Xen riscv64 tests.
+
+Parsing steps:
+    - validate the YAML
+    - build the dataclasses: the host device tree is compiled on first use
+      of MachineConfig.dt
+"""
+
+from __future__ import annotations
+
+import functools
+import inspect
+from dataclasses import dataclass
+from functools import cached_property
+from pathlib import Path
+
+import yaml
+
+from .paths import resolve_binary, resolve_path
+from .xen_dt import DeviceTree, build_xen_device_tree
+
+XEN_MMU_TYPE_DEFAULT: str = "sv48"
+XEN_BOOTARGS_DEFAULT: str = ""
+
+MACHINE_MEMORY: int = 2048
+MACHINE_INTERRUPT_CONTROLLER: str = "aplic-imsic"
+
+
+def required_keys(argument_name: str, keys: set, label: str = ""):
+    """Validate the dict passed as `argument_name` has every key in `keys`."""
+    keys = set(keys)
+
+    def decorate(func):
+        sig = inspect.signature(func)
+
+        @functools.wraps(func)
+        def wrapper(*args, **kwargs):
+            arg = sig.bind(*args, **kwargs).arguments[argument_name]
+            missing = keys - arg.keys()
+            if missing:
+                raise ValueError(
+                    f"{label or func.__name__} missing keys: {sorted(missing)}"
+                )
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorate
+
+
+@dataclass
+class BinariesConfig:
+    xen: Path
+
+
+@required_keys("raw", {"xen"}, label="binaries config")
+def _parse_binaries(raw: dict) -> BinariesConfig:
+    return BinariesConfig(xen=resolve_binary(raw["xen"]))
+
+
+@required_keys("raw", {"pcpu"}, label="machine config")
+def _parse_machine(
+    raw: dict,
+    binaries: BinariesConfig,
+    machine: str,
+) -> MachineConfig:
+    return MachineConfig(
+        name=machine,
+        pcpu=raw["pcpu"],
+        binaries=binaries,
+        mmu_type=raw.get("mmu_type", XEN_MMU_TYPE_DEFAULT),
+        xen_bootargs=raw.get("xen_bootargs", XEN_BOOTARGS_DEFAULT),
+    )
+
+
+@dataclass(frozen=True)
+class MachineConfig:
+    """One named machine: the test-agnostic description of what to boot.
+
+    A machine is reusable across test types; a test (see RiscvQtbTest
+    subclasses) picks a machine by name and layers its own parameters on top.
+    """
+
+    name: str
+    pcpu: int
+    binaries: BinariesConfig
+    mmu_type: str  # Xen (host) MMU type, injected into the host dts cpus.
+    xen_bootargs: str
+
+    @classmethod
+    def from_config(cls, file_name: str, machine: str) -> MachineConfig:
+        """Build only the single named machine from the catalog at `path`.
+
+        A test run boots one machine, so there is no need to construct the
+        whole catalog: parse the YAML, validate the shared binaries, and
+        build just the requested entry.
+        """
+        fpath: Path = resolve_path(file_name)
+        raw: dict = yaml.safe_load(fpath.read_text())
+
+        required = ("binaries", "machines")
+        missing = [k for k in required if k not in raw]
+        if missing:
+            raise ValueError(f"Global config {file_name} missing keys: {missing}")
+
+        binaries: BinariesConfig = _parse_binaries(raw["binaries"])
+
+        machines = raw["machines"]
+        if machine not in machines:
+            known = ", ".join(sorted(machines)) or "(none)"
+            raise ValueError(f"unknown machine {machine!r}; known machines: {known}")
+
+        return _parse_machine(machines[machine], binaries, machine)
+
+    @cached_property
+    def dt(self) -> DeviceTree:
+        """Host device tree, compiled on first use."""
+        return build_xen_device_tree(self)
diff --git a/automation/scripts/qtb/riscv/config.yaml b/automation/scripts/qtb/riscv/config.yaml
new file mode 100644
index 0000000000..94897827b7
--- /dev/null
+++ b/automation/scripts/qtb/riscv/config.yaml
@@ -0,0 +1,17 @@
+# Shared config for the qtb riscv64 tests
+#
+# A machine is the test-agnostic description of what to boot (cpus, Xen command
+# line). Test YAMLs (e.g. console-test.yaml) pick a machine by name and layer
+# their own parameters on top.
+#
+# Path resolution (see paths.py): `binaries:` entries resolve against
+# $QTB_BINARIES_DIR env var if defined else `binaries`. Absolute paths used
+# as-is.
+
+binaries:
+  xen:      xen
+
+machines:
+  dom0less-1smp-0domu-1vcpu-aplic-imsic-null:
+    xen_bootargs: "sched=null"
+    pcpu: 1
diff --git a/automation/scripts/qtb/riscv/console_test/__init__.py b/automation/scripts/qtb/riscv/console_test/__init__.py
new file mode 100644
index 0000000000..5db5569965
--- /dev/null
+++ b/automation/scripts/qtb/riscv/console_test/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""console-test type package."""
+
+from .console_test import ConsoleTest
diff --git a/automation/scripts/qtb/riscv/console_test/console-test.yaml b/automation/scripts/qtb/riscv/console_test/console-test.yaml
new file mode 100644
index 0000000000..cec0edc510
--- /dev/null
+++ b/automation/scripts/qtb/riscv/console_test/console-test.yaml
@@ -0,0 +1,18 @@
+# Console string expectation test (run with: qemu_smoke_riscv64.py console-test run <test>).
+#
+# Each test uses a machine from config.yaml and maps a console index to the list
+# of string(s) expected on that console: 0 is Xen's own console. The runner boots
+# the machine and asserts each string is printed within the timeout. Nothing is
+# injected.
+#
+# Test options:
+#   timeout: int  # timeout between each string match (in seconds)
+#   attempts: int # number of tries for a wait before failing. Default 3 (min = 1)
+
+machine_catalog: config.yaml
+
+tests:
+  dom0less-1smp-0domu-1vcpu-aplic-imsic-null:
+    machine: dom0less-1smp-0domu-1vcpu-aplic-imsic-null
+    expect:
+      0: ["All set up"]
diff --git a/automation/scripts/qtb/riscv/console_test/console_test.py b/automation/scripts/qtb/riscv/console_test/console_test.py
new file mode 100644
index 0000000000..95540fd388
--- /dev/null
+++ b/automation/scripts/qtb/riscv/console_test/console_test.py
@@ -0,0 +1,145 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Console string expectation test (`type: console-test`).
+
+Per console: assert each expected string is printed within the timeout.
+Nothing is injected; this only watches console output.
+
+YAML (console-test.yaml): each test names the machine it boots (tests may
+share one) and maps a console index to the string(s) expected on it: 0 is
+Xen's own console.
+
+    machine_catalog: config.yaml
+    tests:
+      dom0less-1smp-0domu-1vcpu-aplic-imsic-null:  # test name (run positional)
+        machine: dom0less-1smp-0domu-1vcpu-aplic-imsic-null  # from config.yaml
+        expect:                                    # console index -> string(s)
+          0: [All set up]
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import ClassVar
+
+import pexpect
+import yaml
+
+from ..paths import resolve_path
+from ..config import MachineConfig, required_keys
+from ..qtb_test import TIMEOUT_DEFAULT, RiscvQtbTest
+from ..machine import RiscvTestMachine
+
+logger = logging.getLogger(__name__)
+
+TYPE_ID: str = "console-test"
+
+CONFIG_FILE_DEFAULT: str = "console_test/console-test.yaml"
+DESCRIPTION_DEFAULT: str = "Assert expected string(s) are printed on the Xen console"
+ATTEMPTS_DEFAULT: int = 3
+
+XEN_CONS_IDX: int = 0
+
+
+class ConsoleTest(RiscvQtbTest):
+    type_id: ClassVar[str] = TYPE_ID
+    description: ClassVar[str] = DESCRIPTION_DEFAULT
+    config_file: ClassVar[str] = CONFIG_FILE_DEFAULT
+
+    def __init__(self, raw: dict, test_name: str) -> None:
+        self.name, self.data, self.machine = self._parse_test_cfg(raw, test_name)
+
+        self.expect = self.data["expect"]  # expected console string
+
+        self.timeout = int(self.data.get("timeout", TIMEOUT_DEFAULT))
+        if self.timeout < 1:
+            raise ValueError("timeout < 1, must be at least 1")
+
+        self.attempts = int(self.data.get("attempts", ATTEMPTS_DEFAULT))
+        if self.attempts < 1:
+            raise ValueError("attempts < 1, must be at least 1")
+
+    @staticmethod
+    def _load_yaml(path) -> dict:
+        return yaml.safe_load(resolve_path(path).read_text())
+
+    @classmethod
+    def from_config(cls, config_file: str, test_name: str) -> ConsoleTest:
+        return cls(cls._load_yaml(config_file), test_name)
+
+    @staticmethod
+    @required_keys("test_data", {"machine", "expect"})
+    def _parse_test_data(
+        machine_catalog: str, test_data: dict, test_name: str
+    ) -> tuple[str, dict, MachineConfig]:
+        """Parse test data dictionary"""
+        test_machine = MachineConfig.from_config(machine_catalog, test_data["machine"])
+
+        def invalid(why: str) -> ValueError:
+            return ValueError(
+                f"test {test_name!r}: {why}; expected "
+                f"{{{XEN_CONS_IDX}: ['str1', 'str2', ...]}}"
+            )
+
+        expect = test_data["expect"]
+        if not isinstance(expect, dict) or expect.keys() != {XEN_CONS_IDX}:
+            raise invalid(
+                f"expect must map console index {XEN_CONS_IDX} (Xen's own "
+                f"console, the only one) and nothing else, got {expect!r}"
+            )
+        strings = expect[XEN_CONS_IDX]
+        if not isinstance(strings, list):
+            raise invalid(
+                f"{ConsoleTest.config_file} expects a list of string(s), "
+                f"got {type(strings).__name__}"
+            )
+        if not strings:
+            raise invalid("Xen has no expected string")
+        if not all(isinstance(s, str) and s for s in strings):
+            raise invalid(f"Xen expects non-empty strings, got {strings!r}")
+        return (test_name, test_data, test_machine)
+
+    @staticmethod
+    @required_keys("raw", {"machine_catalog", "tests"})
+    def _parse_test_cfg(raw: dict, test_name: str) -> tuple[str, dict, MachineConfig]:
+        """Read the config: return the named test dict and its machine."""
+
+        tests = raw["tests"]
+        if test_name not in tests:
+            known = ", ".join(sorted(tests)) or "(none)"
+            raise ValueError(f"unknown test {test_name!r}; known tests: {known}")
+
+        test_data = tests[test_name]
+        return ConsoleTest._parse_test_data(
+            raw["machine_catalog"], test_data, test_name
+        )
+
+    @staticmethod
+    def list_tests(config_file: str) -> list[str]:
+        tests = ConsoleTest._load_yaml(config_file).get("tests")
+        if not tests:
+            logger.warning("no 'tests' key found in %s", config_file)
+            return []
+        return list(tests)
+
+    @staticmethod
+    def _console(vm: RiscvTestMachine):
+        """Xen's own console (con0)."""
+        if vm.console is None:
+            raise RuntimeError("Xen console not wired up, machine not launched?")
+        return vm.console
+
+    def run(self, vm: RiscvTestMachine) -> None:
+        cons = self._console(vm)
+        for strings in self.expect.values():
+            for s in strings:
+                self._expect_string(cons, s)
+
+    def _expect_string(self, cons, expected: str) -> None:
+        """Wait for `expected` on the console, retrying on timeout."""
+        for attempt in range(self.attempts):
+            try:
+                cons.expect_exact(expected, timeout=self.timeout)
+                return
+            except pexpect.TIMEOUT:
+                if attempt == self.attempts - 1:
+                    raise
diff --git a/automation/scripts/qtb/riscv/dt.py b/automation/scripts/qtb/riscv/dt.py
new file mode 100644
index 0000000000..f0376979e5
--- /dev/null
+++ b/automation/scripts/qtb/riscv/dt.py
@@ -0,0 +1,57 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Device tree (DT) handling: compile a .dts source into a .dtb"""
+
+from __future__ import annotations
+
+import logging
+import subprocess
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+def _compile_dts(src: Path, out: Path):
+    """Run dtc to compile .dts `src` into the .dtb file at `out`"""
+    try:
+        p = subprocess.run(
+            ["dtc", "-I", "dts", "-O", "dtb", "-o", str(out), str(src)],
+            check=True,
+            capture_output=True,
+            text=True,
+        )
+        logger.debug("dtc %s: stdout: %s stderr: %s", src, p.stdout, p.stderr)
+
+    except FileNotFoundError as e:
+        raise RuntimeError("dtc not found in PATH; install device-tree-compiler") from e
+    except subprocess.CalledProcessError as e:
+        raise RuntimeError(
+            f"dtc failed on {str(src)!r} (exit {e.returncode}):\n"
+            f" stdout: {e.stdout}\n stderr: {e.stderr}"
+        ) from e
+
+
+def compile_to_dtb(src: Path, out: Path) -> Path:
+    """
+    Compile a .dts source to a .dtb under out dir and return the .dtb path.
+
+    Raises FileNotFoundError if `src` or `out` don't exist.
+    """
+    if not src.exists():
+        raise FileNotFoundError(
+            f"Device tree source {str(src)!r} not found"
+        )
+
+    if not out.exists():
+        raise FileNotFoundError(f"Device Tree output dir: {out} doesn't exist")
+
+    dtb = out / (src.stem + ".dtb")
+    _compile_dts(src, dtb)
+    return dtb
+
+
+def write_dts(text: str, name: str, out_dir: Path) -> Path:
+    """Write generated .dts text to <out_dir>/<name>.dts; return its path."""
+    src = out_dir / f"{name}.dts"
+    src.parent.mkdir(parents=True, exist_ok=True)
+    src.write_text(text)
+    return src
diff --git a/automation/scripts/qtb/riscv/machine.py b/automation/scripts/qtb/riscv/machine.py
new file mode 100644
index 0000000000..74a05dfc82
--- /dev/null
+++ b/automation/scripts/qtb/riscv/machine.py
@@ -0,0 +1,57 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""QtbMachine for riscv64."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from qemu.qtb import QtbMachine
+
+from .config import MACHINE_INTERRUPT_CONTROLLER, MACHINE_MEMORY, MachineConfig
+from .paths import resolve_from_path
+
+
+class RiscvTestMachine(QtbMachine):
+    arch_name = "riscv64"
+    gdb_arch = "riscv:rv64"
+
+    qemu_bin = "qemu-system-riscv64"
+
+    def __init__(
+        self,
+        mc: MachineConfig,
+        *,
+        timeout: int,
+        log_dir: str | None = None,
+    ) -> None:
+        self.machine_conf = mc
+        super().__init__(
+            memory=MACHINE_MEMORY,
+            cpus=mc.pcpu,
+            mirror_console=False,
+            timeout=timeout,
+            log_dir=log_dir,
+        )
+
+    def _machine_args(self, memory: int, cpus: int) -> Sequence[str]:
+        machine = self.machine_conf
+        machine_opt = f"virt,aclint=off,aia={MACHINE_INTERRUPT_CONTROLLER}"
+        # Xen has no sstc support yet.
+        cpu_opt = "rv64,svpbmt=on,smstateen=on,sstc=off"
+        return [
+            "-dtb",
+            str(machine.dt.dtb),
+            "-M",
+            machine_opt,
+            "-cpu",
+            cpu_opt,
+            "-smp",
+            str(cpus),
+            "-m",
+            str(memory),
+            "-kernel",
+            str(machine.binaries.xen),
+        ]
+
+    def _resolve_binary(self) -> str:
+        return str(resolve_from_path(self.qemu_bin))
diff --git a/automation/scripts/qtb/riscv/paths.py b/automation/scripts/qtb/riscv/paths.py
new file mode 100644
index 0000000000..237f1bf502
--- /dev/null
+++ b/automation/scripts/qtb/riscv/paths.py
@@ -0,0 +1,60 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Path helpers: resolve pkg-relative paths."""
+
+from __future__ import annotations
+
+import os
+import shutil
+import tempfile
+from functools import lru_cache
+from pathlib import Path
+
+# Every relative path in this module is resolved against this base.
+_BASE = Path(__file__).resolve().parent
+
+# Build artifacts, overridable for CI.
+_BINARIES_BASE = Path(os.environ.get("QTB_BINARIES_DIR") or _BASE / "binaries")
+
+
+@lru_cache(maxsize=1)
+def _temp_dir_handle() -> tempfile.TemporaryDirectory:
+    return tempfile.TemporaryDirectory(prefix="qtb-")
+
+
+def temp_dir() -> Path:
+    """Process-wide scratch dir for generated/compiled artifacts (singleton)."""
+    return Path(_temp_dir_handle().name)
+
+
+def cleanup_temp_dir() -> None:
+    """Remove the scratch dir, if one was created, and clear the cache."""
+    if _temp_dir_handle.cache_info().currsize:
+        _temp_dir_handle().cleanup()
+        _temp_dir_handle.cache_clear()
+
+
+def resolve_path(file_name: str) -> Path:
+    """Resolve `file_name` against _BASE, absolute paths pass through."""
+    return _resolve_under(file_name, _BASE)
+
+
+def resolve_binary(file_name: str) -> Path:
+    """Resolve a build artifact against _BINARIES_BASE, absolute paths pass through."""
+    return _resolve_under(file_name, _BINARIES_BASE)
+
+
+def resolve_from_path(file_name: str) -> Path:
+    """Resolve an executable on $PATH, absolute paths pass through."""
+    path = shutil.which(file_name)
+    if not path:
+        raise FileNotFoundError(f"cannot resolve {file_name!r}: not an executable on $PATH")
+    return Path(path)
+
+
+def _resolve_under(file_name: str, base: Path) -> Path:
+    """Resolve `file_name` against `base`, absolute paths pass through."""
+    p = Path(file_name)
+    path = p if p.is_absolute() else base / p
+    if not path.exists():
+        raise FileNotFoundError(f"cannot resolve {str(path)!r}: does not exist")
+    return path
diff --git a/automation/scripts/qtb/riscv/qtb_test.py b/automation/scripts/qtb/riscv/qtb_test.py
new file mode 100644
index 0000000000..872fbc2fa9
--- /dev/null
+++ b/automation/scripts/qtb/riscv/qtb_test.py
@@ -0,0 +1,53 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Abstract base for the qtb riscv64 test types.
+
+A test type is a RiscvQtbTest subclass owning a config file that describes its
+tests, each bound to a machine from the shared catalog.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import ClassVar
+
+from .config import MachineConfig
+from .machine import RiscvTestMachine
+
+# Default per-test timeout (seconds)
+TIMEOUT_DEFAULT: int = 120
+
+
+class RiscvQtbTest(ABC):
+    """One runnable test bound to the machine it boots.
+
+    A subclass sets `type_id`, `description`, and `config_file`, and implements
+    `from_config` to parse its config file, `list_tests` to enumerate the tests
+    it declares, and `run` to drive the test logic.
+    """
+
+    # Set by each concrete subclass.
+    type_id: ClassVar[str] = ""
+    # One-line summary of what the type does, shown in the CLI help.
+    description: ClassVar[str] = ""
+    # Config file the type reads its tests from, resolved pkg-relative.
+    config_file: ClassVar[str] = ""
+
+    # Set by the subclass parser.
+    name: str
+    data: dict
+    machine: MachineConfig
+    timeout: int = TIMEOUT_DEFAULT
+
+    @classmethod
+    @abstractmethod
+    def from_config(cls, config_file: str, test_name: str) -> RiscvQtbTest:
+        """Build the test named `test_name` from `config_file`."""
+
+    @staticmethod
+    @abstractmethod
+    def list_tests(config_file: str) -> list[str]:
+        """Return the names of every test declared in the config file."""
+
+    @abstractmethod
+    def run(self, vm: RiscvTestMachine) -> None:
+        """Drive the running machine and assert the expected result."""
diff --git a/automation/scripts/qtb/riscv/xen_dt.py b/automation/scripts/qtb/riscv/xen_dt.py
new file mode 100644
index 0000000000..8881b01b36
--- /dev/null
+++ b/automation/scripts/qtb/riscv/xen_dt.py
@@ -0,0 +1,58 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Build the Xen host device tree for a MachineConfig.
+
+The tree is rendered from its Jinja2 template (dts/qemu-host.dts.j2), which
+takes the hart count, the Xen MMU type and the Xen command line, then compiled
+to a DTB with dtc.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+from typing import TYPE_CHECKING
+from jinja2 import Environment, FileSystemLoader
+
+from .paths import resolve_path, temp_dir
+from .dt import compile_to_dtb, write_dts
+
+if TYPE_CHECKING:  # config imports this module, so only import it for typing.
+    from .config import MachineConfig
+
+# Directory holding the Jinja2 platform device tree templates.
+_DTS_DIR = "dts"
+
+
+@dataclass(frozen=True)
+class DeviceTree:
+    """Compiled device trees for one machine launch."""
+
+    dts: Path
+    dtb: Path
+
+
+@lru_cache(maxsize=1)
+def _env() -> Environment:
+    return Environment(
+        loader=FileSystemLoader(resolve_path(_DTS_DIR)),
+        keep_trailing_newline=True,
+    )
+
+
+def _render_xen_dts(machine: MachineConfig) -> str:
+    """Render the Xen host device tree source text for `machine`."""
+    tmpl = _env().get_template("qemu-host.dts.j2")
+    return tmpl.render(
+        ncpus=machine.pcpu,
+        mmu_type=machine.mmu_type,
+        xen_bootargs=machine.xen_bootargs,
+    )
+
+
+def build_xen_device_tree(machine: MachineConfig) -> DeviceTree:
+    """Compile `machine` device tree into the shared scratch dir."""
+    out = temp_dir()
+    dts: Path = write_dts(_render_xen_dts(machine), machine.name, out)
+    dtb: Path = compile_to_dtb(dts, out)
+    return DeviceTree(dts=dts, dtb=dtb)


^ permalink raw reply related	[flat|nested] 9+ messages in thread

* [PATCH v2 4/6] automation/qtb: add unit tests for the QTB framework
  2026-08-27  9:35 [PATCH v2 0/6] automation: add QTB test framework for riscv64 smoke tests Baptiste Le Duc
                   ` (2 preceding siblings ...)
  2026-08-27  9:42 ` [PATCH v2 3/6] automation/qtb: add Python QTB framework with the console-test type Baptiste Le Duc
@ 2026-08-27  9:42 ` Baptiste Le Duc
  2026-08-27  9:42 ` [PATCH v2 5/6] automation/qtb: add QTB framework README Baptiste Le Duc
  2026-08-27  9:42 ` [PATCH v2 6/6] CI: run the riscv64 smoke test via QTB framework console-test Baptiste Le Duc
  5 siblings, 0 replies; 9+ messages in thread
From: Baptiste Le Duc @ 2026-08-27  9:42 UTC (permalink / raw)
  To: xen-devel; +Cc: Baptiste Le Duc, Doug Goldstein, Stefano Stabellini

The QTB framework is meant to be extended: new test types, new machines and
new device trees aim to be added by other people.

Add pytest coverage of the framework's functions, and of the console-test
type's config validation and expect/retry loop, so that such changes get
immediate feedback and existing behaviour does not silently regress.

The suite covers 100% of the framework's statements, so a new code path
added without a test shows up as a coverage drop.

The tests are meant to be run locally, from the Xen tree root:

    python3 -m pytest automation/scripts/qtb/riscv/unit/

and, with pytest-cov installed, the coverage report is:

    python3 -m pytest --cov=automation/scripts/qtb \
        automation/scripts/qtb/riscv/unit/

The tests drive fakes rather than QEMU, so they need no artifacts to run.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Baptiste Le Duc <baptiste.le-duc@vates.tech>
---
 automation/scripts/qtb/riscv/unit/__init__.py |   2 +
 automation/scripts/qtb/riscv/unit/conftest.py |  42 ++++
 .../scripts/qtb/riscv/unit/test_config.py     | 121 ++++++++++
 .../qtb/riscv/unit/test_console_test.py       | 217 ++++++++++++++++++
 automation/scripts/qtb/riscv/unit/test_dt.py  |  99 ++++++++
 .../scripts/qtb/riscv/unit/test_machine.py    |  60 +++++
 .../scripts/qtb/riscv/unit/test_temp_dir.py   |  42 ++++
 .../scripts/qtb/riscv/unit/test_xen_dt.py     |  46 ++++
 8 files changed, 629 insertions(+)
 create mode 100644 automation/scripts/qtb/riscv/unit/__init__.py
 create mode 100644 automation/scripts/qtb/riscv/unit/conftest.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_config.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_console_test.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_dt.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_machine.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_temp_dir.py
 create mode 100644 automation/scripts/qtb/riscv/unit/test_xen_dt.py

diff --git a/automation/scripts/qtb/riscv/unit/__init__.py b/automation/scripts/qtb/riscv/unit/__init__.py
new file mode 100644
index 0000000000..b234dc5303
--- /dev/null
+++ b/automation/scripts/qtb/riscv/unit/__init__.py
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""pytest tests of the framework's own logic."""
diff --git a/automation/scripts/qtb/riscv/unit/conftest.py b/automation/scripts/qtb/riscv/unit/conftest.py
new file mode 100644
index 0000000000..576eaf13b1
--- /dev/null
+++ b/automation/scripts/qtb/riscv/unit/conftest.py
@@ -0,0 +1,42 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Shared pytest fixtures for the qtb unit tests."""
+
+from __future__ import annotations
+
+import pytest
+
+from ..config import MachineConfig
+
+
+@pytest.fixture
+def make_file(tmp_path):
+    """Return a factory creating a file of `size` bytes, yielding its path."""
+
+    def _make(name: str, size: int = 16) -> str:
+        p = tmp_path / name
+        p.write_bytes(b"\0" * size)
+        return str(p)
+
+    return _make
+
+
+@pytest.fixture
+def make_machine():
+    """Return a factory building a MachineConfig for tests."""
+
+    def _make(
+        *,
+        name="m",
+        binaries=None,
+        mmu="sv48",
+        xen_bootargs="",
+    ) -> MachineConfig:
+        return MachineConfig(
+            name=name,
+            pcpu=4,
+            binaries=binaries,
+            mmu_type=mmu,
+            xen_bootargs=xen_bootargs,
+        )
+
+    return _make
diff --git a/automation/scripts/qtb/riscv/unit/test_config.py b/automation/scripts/qtb/riscv/unit/test_config.py
new file mode 100644
index 0000000000..422ba2a5ea
--- /dev/null
+++ b/automation/scripts/qtb/riscv/unit/test_config.py
@@ -0,0 +1,121 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Unit tests for the YAML machine-catalog parser."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+import yaml
+
+from ..config import (
+    XEN_BOOTARGS_DEFAULT,
+    XEN_MMU_TYPE_DEFAULT,
+    MachineConfig,
+    _parse_binaries,
+)
+
+
+@pytest.fixture
+def binaries(make_file):
+    """Raw binaries dict pointing at an existing file."""
+    return {"xen": make_file("xen")}
+
+
+# ---- _parse_binaries ----
+
+
+def test_parse_binaries_resolves_existing_paths(binaries):
+    assert _parse_binaries(binaries).xen == Path(binaries["xen"])
+
+
+def test_parse_binaries_missing_key_raises(binaries):
+    del binaries["xen"]
+    with pytest.raises(ValueError, match="missing keys"):
+        _parse_binaries(binaries)
+
+
+def test_parse_binaries_missing_file_raises(binaries, tmp_path):
+    binaries["xen"] = str(tmp_path / "absent")
+    with pytest.raises(FileNotFoundError):
+        _parse_binaries(binaries)
+
+
+# ---- MachineConfig.from_config ----
+
+
+def _write_yaml(tmp_path, binaries, name="machine-a", **machine_overrides):
+    machine = {
+        "pcpu": 1,
+        "xen_bootargs": "com1=poll sched=null",
+    }
+    machine.update(machine_overrides)
+    doc = {"binaries": binaries, "machines": {name: machine}}
+    path = tmp_path / "config.yaml"
+    path.write_text(yaml.safe_dump(doc))
+    return str(path)
+
+
+def test_from_config_builds_machineconfig(tmp_path, binaries):
+    path = _write_yaml(tmp_path, binaries)
+
+    mc = MachineConfig.from_config(path, "machine-a")
+
+    assert mc.name == "machine-a"
+    assert mc.pcpu == 1
+    assert mc.xen_bootargs == "com1=poll sched=null"
+    assert mc.binaries.xen == Path(binaries["xen"])
+
+
+def test_from_config_applies_optional_defaults(tmp_path, binaries):
+    # A machine with only the required keys falls back to the module defaults.
+    path = _write_yaml(
+        tmp_path,
+        binaries,
+        name="bare",
+        mmu_type=None,
+        xen_bootargs=None,
+    )
+    # Drop the keys set to None so the parser sees them as absent.
+    doc = yaml.safe_load(Path(path).read_text())
+    for k in ("mmu_type", "xen_bootargs"):
+        doc["machines"]["bare"].pop(k, None)
+    Path(path).write_text(yaml.safe_dump(doc))
+
+    mc = MachineConfig.from_config(path, "bare")
+
+    assert mc.mmu_type == XEN_MMU_TYPE_DEFAULT
+    assert mc.xen_bootargs == XEN_BOOTARGS_DEFAULT
+
+
+def test_from_config_missing_machine_key_raises(tmp_path, binaries):
+    path = _write_yaml(tmp_path, binaries, name="bare")
+    doc = yaml.safe_load(Path(path).read_text())
+    del doc["machines"]["bare"]["pcpu"]
+    Path(path).write_text(yaml.safe_dump(doc))
+
+    with pytest.raises(ValueError, match="machine config missing keys"):
+        MachineConfig.from_config(path, "bare")
+
+
+def test_from_config_missing_top_level_key_raises(tmp_path, binaries):
+    path = _write_yaml(tmp_path, binaries)
+    doc = yaml.safe_load(Path(path).read_text())
+    del doc["binaries"]
+    Path(path).write_text(yaml.safe_dump(doc))
+
+    with pytest.raises(ValueError, match="missing keys: \\['binaries'\\]"):
+        MachineConfig.from_config(path, "machine-a")
+
+
+def test_from_config_unknown_machine_raises(tmp_path, binaries):
+    path = _write_yaml(tmp_path, binaries)
+    with pytest.raises(ValueError, match="unknown machine 'nope'"):
+        MachineConfig.from_config(path, "nope")
+
+
+def test_from_config_missing_binary_raises(tmp_path, binaries):
+    binaries["xen"] = str(tmp_path / "gone")
+    path = _write_yaml(tmp_path, binaries)
+    with pytest.raises(FileNotFoundError):
+        MachineConfig.from_config(path, "machine-a")
diff --git a/automation/scripts/qtb/riscv/unit/test_console_test.py b/automation/scripts/qtb/riscv/unit/test_console_test.py
new file mode 100644
index 0000000000..cf15ed3b1b
--- /dev/null
+++ b/automation/scripts/qtb/riscv/unit/test_console_test.py
@@ -0,0 +1,217 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Unit tests for the console-test test type (console_test.py)."""
+
+from __future__ import annotations
+
+from itertools import chain, repeat
+from unittest import mock
+
+import pexpect
+import pytest
+
+from ..console_test.console_test import ConsoleTest
+from ..config import MachineConfig
+
+
+# ---- helpers ----
+
+
+def _parse_test_data(machine, expect, name="dummy"):
+    """Validate `expect` against `machine`, without reading a catalog file."""
+    test_data = {"machine": "box", "expect": expect}
+    with mock.patch.object(MachineConfig, "from_config", return_value=machine):
+        return ConsoleTest._parse_test_data("config.yaml", test_data, name)
+
+
+def _console(fail_times: int = 0, matches: int = 1) -> mock.Mock:
+    """Stand in for a pexpect spawn: `fail_times` timeouts, then `matches` hits.
+
+    Every wait past `matches` times out, so a test that waits more often than it
+    should fails instead of silently passing.
+    """
+    cons = mock.Mock()
+    cons.expect_exact.side_effect = chain(
+        [pexpect.TIMEOUT("nope")] * fail_times,
+        [None] * matches,
+        repeat(pexpect.TIMEOUT("nope")),
+    )
+    return cons
+
+
+def _asked(cons: mock.Mock) -> list[str]:
+    """The strings waited for on `cons`, one entry per attempt (matched or not)."""
+    return [call.args[0] for call in cons.expect_exact.call_args_list]
+
+
+def _test(expect, machine, **opts):
+    """Build a ConsoleTest bound to `machine`, skipping the YAML read."""
+    raw = {
+        "machine_catalog": "config.yaml",
+        "tests": {"dummy": {"machine": "box", "expect": expect, **opts}},
+    }
+    with mock.patch.object(MachineConfig, "from_config", return_value=machine):
+        return ConsoleTest(raw, "dummy")
+
+
+# ---- _parse_test_data ----
+
+
+def test_parse_test_data_accepts_a_list_of_strings(make_machine):
+    machine = make_machine()
+    name, data, got = _parse_test_data(machine, {0: ["Hello", "All set up"]})
+    assert (name, got) == ("dummy", machine)
+    assert data["expect"] == {0: ["Hello", "All set up"]}
+
+
+def test_parse_test_data_bare_string_raises(make_machine):
+    # A bare string is refused, not wrapped: the YAML must spell out the list.
+    machine = make_machine()
+    with pytest.raises(ValueError, match="expects a list of string"):
+        _parse_test_data(machine, {0: "All set up"})
+
+
+@pytest.mark.parametrize(
+    "expect",
+    [
+        {-1: ["All set up"]},  # console index below Xen's
+        {1: ["All set up"]},  # console index above Xen's
+        {0: ["All set up"], 1: ["More"]},  # Xen's + another unknown
+        ["All set up"],  # no console index
+        None,
+        "All set up",  # not a map at all
+    ],
+)
+def test_parse_test_data_not_the_xen_console_map_raises(expect, make_machine):
+    machine = make_machine()
+    with pytest.raises(ValueError, match="must map console index 0"):
+        _parse_test_data(machine, expect)
+
+
+def test_parse_test_data_empty_list_raises(make_machine):
+    machine = make_machine()
+    with pytest.raises(ValueError, match="Xen has no expected string"):
+        _parse_test_data(machine, {0: []})
+
+
+def test_parse_test_data_empty_string_in_list_raises(make_machine):
+    machine = make_machine()
+    with pytest.raises(ValueError, match="Xen expects non-empty strings"):
+        _parse_test_data(machine, {0: ["All set up", ""]})
+
+
+def test_parse_test_data_missing_expect_raises():
+    with pytest.raises(ValueError, match="missing keys"):
+        ConsoleTest._parse_test_data("config.yaml", {"machine": "box"}, "dummy")
+
+
+# ---- _parse_test_cfg ----
+
+
+def test_parse_test_cfg_missing_machine_catalog_raises():
+    with pytest.raises(ValueError, match="missing keys"):
+        ConsoleTest._parse_test_cfg({"tests": {}}, "dummy")
+
+
+def test_parse_test_cfg_unknown_test_raises():
+    raw = {"machine_catalog": "config.yaml", "tests": {"a": {}}}
+    with pytest.raises(ValueError, match="unknown test 'dummy'"):
+        ConsoleTest._parse_test_cfg(raw, "dummy")
+
+
+# ---- __init__ ----
+
+
+def test_timeout_and_attempts_are_read(make_machine):
+    test = _test({0: ["All set up"]}, make_machine(), timeout=7, attempts=2)
+    assert (test.timeout, test.attempts) == (7, 2)
+
+
+def test_timeout_below_one_raises(make_machine):
+    with pytest.raises(ValueError, match="timeout < 1"):
+        _test({0: ["All set up"]}, make_machine(), timeout=0)
+
+
+def test_attempts_below_one_raises(make_machine):
+    with pytest.raises(ValueError, match="attempts < 1"):
+        _test({0: ["All set up"]}, make_machine(), attempts=0)
+
+
+# ---- run ----
+
+
+def test_run_expects_each_string_in_order_on_con0(make_machine):
+    test = _test({0: ["first", "then"]}, make_machine())
+    vm = mock.Mock(console=_console(matches=2))
+
+    test.run(vm)
+
+    assert _asked(vm.console) == ["first", "then"]
+
+
+def test_run_xen_console_not_wired_raises(make_machine):
+    test = _test({0: ["All set up"]}, make_machine())
+    vm = mock.Mock(console=None)
+
+    with pytest.raises(RuntimeError, match="not launched"):
+        test.run(vm)
+
+
+# ---- _expect_string ----
+
+
+def test_expect_string_retries_after_a_timeout(make_machine):
+    test = _test({0: ["All set up"]}, make_machine(), attempts=3)
+    cons = _console(fail_times=2)
+
+    test._expect_string(cons, "All set up")
+
+    assert _asked(cons) == ["All set up"] * 3
+
+
+def test_expect_string_raises_once_attempts_are_spent(make_machine):
+    test = _test({0: ["All set up"]}, make_machine(), attempts=2)
+    cons = _console(matches=0)
+
+    with pytest.raises(pexpect.TIMEOUT):
+        test._expect_string(cons, "All set up")
+
+    assert _asked(cons) == ["All set up"] * 2
+
+
+# ---- config IO ----
+
+
+def test_list_tests_reads_the_type_yaml():
+    names = ConsoleTest.list_tests("console_test/console-test.yaml")
+    assert "dom0less-1smp-0domu-1vcpu-aplic-imsic-null" in names
+
+
+def test_list_tests_without_a_tests_key_returns_empty():
+    with mock.patch.object(ConsoleTest, "_load_yaml", return_value={}):
+        assert ConsoleTest.list_tests("console-test.yaml") == []
+
+
+def test_from_config_builds_instance(make_machine):
+    machine = make_machine()
+
+    raw = {
+        "machine_catalog": "config.yaml",
+        "tests": {"dummy": {"machine": "box", "expect": {0: ["All set up"]}}},
+    }
+    # Mock the YAML read and the catalog lookup: only the build logic is under test.
+    with (
+        mock.patch.object(ConsoleTest, "_load_yaml", return_value=raw),
+        mock.patch.object(MachineConfig, "from_config", return_value=machine),
+    ):
+        test = ConsoleTest.from_config("console-test.yaml", "dummy")
+
+    assert test.name == "dummy"
+    assert test.type_id == "console-test"
+    assert test.machine is machine
+    assert test.expect == {0: ["All set up"]}
+
+
+def test_from_config_unknown_test_raises():
+    # Also pins from_config's argument order (config file, then test name).
+    with pytest.raises(ValueError, match="unknown test 'nope'"):
+        ConsoleTest.from_config("console_test/console-test.yaml", "nope")
diff --git a/automation/scripts/qtb/riscv/unit/test_dt.py b/automation/scripts/qtb/riscv/unit/test_dt.py
new file mode 100644
index 0000000000..2201a8651f
--- /dev/null
+++ b/automation/scripts/qtb/riscv/unit/test_dt.py
@@ -0,0 +1,99 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Unit tests for the device-tree compile path (dt.py)."""
+
+from __future__ import annotations
+
+import shutil
+from unittest import mock
+
+import pytest
+
+from .. import dt
+
+_HAS_DTC = shutil.which("dtc") is not None
+_MINIMAL_DTS = "/dts-v1/;\n/ { };\n"
+
+
+# ---- _compile_dts (dtc wrapper) ----
+
+
+def test_compile_dts_invokes_dtc(tmp_path):
+    src = tmp_path / "in.dts"
+    src.write_text(_MINIMAL_DTS)
+    dtb = tmp_path / "in.dtb"
+
+    with mock.patch.object(dt.subprocess, "run") as run:
+        dt._compile_dts(src, dtb)
+
+    run.assert_called_once()
+    argv = run.call_args.args[0]
+    assert argv == ["dtc", "-I", "dts", "-O", "dtb", "-o", str(dtb), str(src)]
+    assert run.call_args.kwargs["check"] is True
+    assert run.call_args.kwargs["capture_output"] is True
+
+
+def test_compile_dts_missing_dtc_raises_runtimeerror(tmp_path):
+    src, dtb = tmp_path / "a.dts", tmp_path / "a.dtb"
+    src.write_text(_MINIMAL_DTS)
+
+    with mock.patch.object(dt.subprocess, "run", side_effect=FileNotFoundError):
+        with pytest.raises(RuntimeError, match="dtc not found"):
+            dt._compile_dts(src, dtb)
+
+
+def test_compile_dts_dtc_failure_raises_runtimeerror(tmp_path):
+    src, dtb = tmp_path / "a.dts", tmp_path / "a.dtb"
+    src.write_text(_MINIMAL_DTS)
+    err = dt.subprocess.CalledProcessError(1, "dtc", output="out", stderr="syntax error")
+
+    with mock.patch.object(dt.subprocess, "run", side_effect=err):
+        with pytest.raises(RuntimeError, match="syntax error"):
+            dt._compile_dts(src, dtb)
+
+
+# ---- compile_to_dtb path handling ----
+
+
+def test_compile_to_dtb_missing_source_raises_filenotfound(tmp_path):
+    with pytest.raises(FileNotFoundError, match="not found"):
+        dt.compile_to_dtb(tmp_path / "nope.dts", tmp_path)
+
+
+def test_compile_to_dtb_missing_out_dir_raises_filenotfound(tmp_path):
+    src = tmp_path / "a.dts"
+    src.write_text(_MINIMAL_DTS)
+
+    with pytest.raises(FileNotFoundError, match="doesn't exist"):
+        dt.compile_to_dtb(src, tmp_path / "absent")
+
+
+def test_compile_to_dtb_source_goes_to_out_dir_with_dtb_suffix(tmp_path):
+    src = tmp_path / "host-1smp.dts"
+    src.write_text(_MINIMAL_DTS)
+    out = tmp_path / "binaries"
+    out.mkdir()
+
+    with mock.patch.object(dt, "_compile_dts") as compile_mock:
+        result = dt.compile_to_dtb(src, out)
+
+    assert result == out / "host-1smp.dtb"
+    compile_mock.assert_called_once_with(src, out / "host-1smp.dtb")
+
+
+# ---- write_dts ----
+
+
+def test_write_dts_writes_source_under_out_dir(tmp_path):
+    src = dt.write_dts(_MINIMAL_DTS, name="unit", out_dir=tmp_path)
+
+    assert src == tmp_path / "unit.dts"
+    assert src.read_text() == _MINIMAL_DTS
+
+
+@pytest.mark.skipif(not _HAS_DTC, reason="dtc not installed")
+def test_write_then_compile_produces_dtb(tmp_path):
+    src = dt.write_dts(_MINIMAL_DTS, name="unit", out_dir=tmp_path)
+    dtb = dt.compile_to_dtb(src, tmp_path)
+
+    assert dtb.is_file()
+    assert dtb.suffix == ".dtb"
diff --git a/automation/scripts/qtb/riscv/unit/test_machine.py b/automation/scripts/qtb/riscv/unit/test_machine.py
new file mode 100644
index 0000000000..68a1564072
--- /dev/null
+++ b/automation/scripts/qtb/riscv/unit/test_machine.py
@@ -0,0 +1,60 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Unit tests for RiscvTestMachine QEMU argument assembly."""
+
+from __future__ import annotations
+
+from stat import S_IEXEC
+from unittest import mock
+
+from ..machine import RiscvTestMachine
+from ..config import MACHINE_MEMORY, BinariesConfig
+
+
+def _binaries(tmp_path):
+    xen = tmp_path / "xen"
+    xen.write_bytes(b"")
+    return BinariesConfig(xen=xen)
+
+
+def _machine(mc) -> RiscvTestMachine:
+    """Build the machine with QtbMachine.__init__ stubbed out (it spawns QEMU)."""
+    with mock.patch("qemu.qtb.QtbMachine.__init__", return_value=None):
+        return RiscvTestMachine(mc, timeout=30)
+
+
+def test_init_forwards_cpus_to_qtbmachine(tmp_path, make_machine):
+    mc = make_machine(name="machine", binaries=_binaries(tmp_path))
+
+    with mock.patch("qemu.qtb.QtbMachine.__init__", return_value=None) as base_init:
+        vm = RiscvTestMachine(mc, timeout=30, log_dir="/logs")
+
+    assert vm.machine_conf is mc
+    assert base_init.call_args.kwargs == {
+        "memory": MACHINE_MEMORY,
+        "cpus": mc.pcpu,
+        "mirror_console": False,
+        "timeout": 30,
+        "log_dir": "/logs",
+    }
+
+
+def test_resolve_qemu_on_path(tmp_path, make_machine, monkeypatch):
+    qemu = tmp_path / "qemu-system-riscv64"
+    qemu.write_bytes(b"")
+    qemu.chmod(S_IEXEC)
+    monkeypatch.setenv("PATH", str(tmp_path))
+    mc = make_machine(name="machine", binaries=_binaries(tmp_path))
+
+    assert _machine(mc)._resolve_binary() == str(qemu)
+
+
+
+def test_machine_args_wires_kernel_and_dtb_but_no_bios(tmp_path, make_machine):
+    mc = make_machine(name="machine", binaries=_binaries(tmp_path))
+
+    args = list(_machine(mc)._machine_args(memory=2048, cpus=4))
+
+    assert args[args.index("-kernel") + 1] == str(mc.binaries.xen)
+    assert args[args.index("-dtb") + 1] == str(mc.dt.dtb)
+    assert args[args.index("-m") + 1] == "2048"
+    assert args[args.index("-smp") + 1] == "4"
diff --git a/automation/scripts/qtb/riscv/unit/test_temp_dir.py b/automation/scripts/qtb/riscv/unit/test_temp_dir.py
new file mode 100644
index 0000000000..d595d824d7
--- /dev/null
+++ b/automation/scripts/qtb/riscv/unit/test_temp_dir.py
@@ -0,0 +1,42 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Unit tests for the temp_dir scratch-directory singleton."""
+
+from __future__ import annotations
+
+import pytest
+
+from .. import paths
+
+
+@pytest.fixture(autouse=True)
+def _reset_singleton():
+    # Force each test to begin with fresh temp dir
+    yield
+    paths.cleanup_temp_dir()
+
+
+def test_temp_dir_exists_and_prefixed():
+    d = paths.temp_dir()
+    assert d.is_dir()
+    assert d.name.startswith("qtb-")
+
+
+def test_temp_dir_is_singleton():
+    assert paths.temp_dir() == paths.temp_dir()
+
+
+def test_temp_dir_handle_kept_alive():
+    h = paths._temp_dir_handle()
+    assert h is paths._temp_dir_handle()
+    assert h.name == str(paths.temp_dir())
+
+
+def test_cleanup_temp_dir_removes_and_resets():
+    d = paths.temp_dir()
+    assert d.is_dir()
+    paths.cleanup_temp_dir()
+    assert not d.exists()
+    # Cache reset: next call builds a fresh, existing dir, not the gone one.
+    fresh = paths.temp_dir()
+    assert fresh.is_dir()
+    assert fresh != d
diff --git a/automation/scripts/qtb/riscv/unit/test_xen_dt.py b/automation/scripts/qtb/riscv/unit/test_xen_dt.py
new file mode 100644
index 0000000000..37c4055b31
--- /dev/null
+++ b/automation/scripts/qtb/riscv/unit/test_xen_dt.py
@@ -0,0 +1,46 @@
+# SPDX-License-Identifier: GPL-2.0-only
+"""Unit tests for xen_dt device-tree generation."""
+
+from __future__ import annotations
+
+from unittest import mock
+
+from .. import xen_dt
+
+
+# ---- _render_xen_dts ----
+
+
+def test_render_injects_bootargs(make_machine):
+    machine = make_machine(xen_bootargs="com1=poll sched=null")
+    out = xen_dt._render_xen_dts(machine)
+
+    assert 'xen,xen-bootargs = "com1=poll sched=null";' in out
+
+
+def test_render_injects_xen_mmu_type(make_machine):
+    machine = make_machine(mmu="sv39")
+    out = xen_dt._render_xen_dts(machine)
+
+    assert 'mmu-type = "riscv,sv39";' in out
+
+
+# ---- build_xen_device_tree ----
+
+
+def test_build_xen_device_tree_compiles(tmp_path, make_machine):
+    machine = make_machine(name="unit-test")
+    dts = tmp_path / "unit-test.dts"
+    dtb = tmp_path / "unit-test.dtb"
+    with (
+        mock.patch.object(xen_dt, "temp_dir", return_value=tmp_path),
+        mock.patch.object(xen_dt, "write_dts", return_value=dts) as write_dts,
+        mock.patch.object(xen_dt, "compile_to_dtb", return_value=dtb) as compile_to_dtb,
+    ):
+        result = xen_dt.build_xen_device_tree(machine)
+
+    write_dts.assert_called_once()
+    assert write_dts.call_args.args[1] == machine.name
+    compile_to_dtb.assert_called_once_with(dts, tmp_path)
+    assert result.dts == dts
+    assert result.dtb == dtb


^ permalink raw reply related	[flat|nested] 9+ messages in thread

* [PATCH v2 5/6] automation/qtb: add QTB framework README
  2026-08-27  9:35 [PATCH v2 0/6] automation: add QTB test framework for riscv64 smoke tests Baptiste Le Duc
                   ` (3 preceding siblings ...)
  2026-08-27  9:42 ` [PATCH v2 4/6] automation/qtb: add unit tests for the QTB framework Baptiste Le Duc
@ 2026-08-27  9:42 ` Baptiste Le Duc
  2026-08-27  9:42 ` [PATCH v2 6/6] CI: run the riscv64 smoke test via QTB framework console-test Baptiste Le Duc
  5 siblings, 0 replies; 9+ messages in thread
From: Baptiste Le Duc @ 2026-08-27  9:42 UTC (permalink / raw)
  To: xen-devel; +Cc: Baptiste Le Duc, Doug Goldstein, Stefano Stabellini

Document the qtb riscv64 test framework in a README.

It covers:
  - the core concepts (machine, test type, test) and how they map to files
  - the source files layout
  - the CLI: `qemu_smoke_riscv64.py <type> <command>`, with "console-test"
    as the type
  - the config files: the machine catalog and a type's own `<type>.yaml`
  - the Jinja2 device-tree templates under dts/
  - how to add a test (config-only) and how to add a new test type.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Baptiste Le Duc <baptiste.le-duc@vates.tech>
---
 automation/scripts/qtb/riscv/README.md | 182 +++++++++++++++++++++++++
 1 file changed, 182 insertions(+)
 create mode 100644 automation/scripts/qtb/riscv/README.md

diff --git a/automation/scripts/qtb/riscv/README.md b/automation/scripts/qtb/riscv/README.md
new file mode 100644
index 0000000000..c173714d75
--- /dev/null
+++ b/automation/scripts/qtb/riscv/README.md
@@ -0,0 +1,182 @@
+qtb riscv64 test framework
+==========================
+
+A small framework that boots Xen under QEMU on riscv64 and drives it to a
+pass/fail verdict automatically from its console output.
+
+It is built on QEMU's qtb (QEMU Test Bench) Python package, which gives
+programmatic control of a QEMU process over QMP and qtest, plus access to the
+consoles.
+
+What it does
+------------
+
+1. Reads a machine description (what to boot: cpus, Xen command line) and a
+   test description (what to assert).
+2. Generates the host device tree.
+3. Launches QEMU with Xen.
+4. Reads the console and checks what Xen printed.
+
+Core concepts
+-------------
+
+- Machine: test-agnostic description of what to boot. Reusable across test
+  types. `config.yaml` -> `MachineConfig`.
+- Test type: a `RiscvQtbTest` subclass implementing the logic of a kind of test
+  (e.g. `console-test`). Identified by `type_id`. See `console_test/`.
+- Test: one named, runnable instance of a type: a machine plus the type's
+  parameters. Lives in the type's `<type>.yaml`.
+
+A test type owns a config file describing its tests, each test names a machine
+from the shared catalog (`config.yaml`) and layers its own parameters on top.
+
+Layout
+------
+
+```
+qemu_smoke_riscv64.py        CLI entry point (<type> run | list)
+
+qtb/riscv/                   This framework
+  __init__.py                Public API
+  qtb_test.py                RiscvQtbTest ABC every test type derives from
+  config.py                  Machine catalog parser -> MachineConfig
+  xen_dt.py                  Generates the host device tree from its Jinja2 template
+  dt.py                      Compile .dts -> .dtb with dtc
+  paths.py                   Path resolution (pkg-relative)
+  machine.py                 RiscvTestMachine: assembles the QEMU command line
+
+  config.yaml                The machine catalog (shared across test types)
+  dts/                       Jinja2 device-tree templates (host, common)
+
+  console_test/              The console-test type
+    __init__.py
+    console_test.py          ConsoleTest implementation
+    console-test.yaml        Its tests
+
+  unit/                      pytest unit tests of the framework logic itself
+```
+
+How a type is selected
+----------------------
+
+The test type is the first positional argument (`qemu_smoke_riscv64.py console-test run
+...`). The CLI builds one subcommand per entry of `TEST_TYPES` (`__init__.py`),
+named after the type's `type_id`.
+
+Each type declares the `config_file` it reads its tests from.
+
+Prerequisites
+-------------
+
+- `qemu.qtb`, QEMU's Python package (`python/` in the QEMU tree)
+- `jinja2`, `pyyaml`, `pexpect`
+- `dtc` (device-tree-compiler)
+- `qemu-system-riscv64`
+- the `xen` binary a machine boots
+
+CLI usage
+---------
+
+Run from `automation/scripts/`, or give the full path from the Xen tree root
+(`./automation/scripts/qemu_smoke_riscv64.py ...`), which is what CI does.
+
+```
+# List every test the type defines in its config:
+./qemu_smoke_riscv64.py console-test list
+
+# Run one test (drives it to PASS/FAIL, exit 0/1):
+./qemu_smoke_riscv64.py console-test run dom0less-1smp-0domu-1vcpu-aplic-imsic-null \
+    --log-dir qtb-logs
+```
+
+`--log-dir` (run only) collects the QEMU process log, the qtest log, and each
+console as `con<N>.log`: `con0.log` is Xen's own console, the only one wired
+up today. Omit it to write no logs. `-v/--verbose` raises the
+log level to debug.
+
+Config files
+------------
+
+`config.yaml` is the machine catalog. `binaries:` are build artifacts resolved
+under `binaries/` (overridable with `$QTB_BINARIES_DIR`); absolute paths pass
+through.
+
+Machine entries omit any optional field left at its default.
+Here are the parameters:
+
+- `pcpu` (required): host physical cpus.
+- `mmu_type` (default `sv48`): Xen host MMU type.
+- `xen_bootargs` (default `""`): Xen command line.
+
+`<type>.yaml` describes the tests of that type.
+
+`console-test`
+--------------
+
+A test names a machine and maps a console index to the string(s) expected on
+that console: index 0 is Xen's own console (`con0`, logged as `con0.log`).
+
+```
+machine_catalog: config.yaml    # the catalog to resolve machine names against
+tests:
+  dom0less-1smp-0domu-1vcpu-aplic-imsic-null:
+    machine: dom0less-1smp-0domu-1vcpu-aplic-imsic-null   # a name in config.yaml
+    expect:
+      0: [All set up]           # Xen itself must print "All set up"
+```
+
+Logic, per console:
+
+1. read the console
+2. wait for each expected string in turn, in the order listed
+3. bound each wait by `timeout` seconds, retrying a timed-out wait up to
+   `attempts` times
+
+The map itself must not be empty, otherwise the test would pass without
+asserting anything.
+
+Device trees (dts/)
+-------------------
+
+Jinja2 template, generated per machine and compiled with dtc:
+
+- `qemu-host.dts.j2` - the Xen host tree: the hart count, the host MMU type and
+  the Xen command line.
+
+Adding a test
+-------------
+
+To add a test to an existing type (e.g. `console-test`):
+
+1. Pick a machine from `config.yaml`, or add a new one under `machines:` (set
+   `pcpu`, and any optional field that differs from its default — see the
+   field list above).
+2. Add a test entry under `tests:` in the type's `<type>.yaml`, naming that
+   machine and supplying the type's own parameters (for `console-test`, one
+   `expect` list per console).
+3. Run it: `./qemu_smoke_riscv64.py console-test run <your-test-name>`.
+
+No code change is needed, a test is pure config.
+
+Adding a new test type
+----------------------
+
+1. Create `mytype/` with `mytype.py` defining a `RiscvQtbTest` subclass: set
+   `type_id`, `description`, and `config_file`, and implement `from_config`,
+   `list_tests`, and `run(vm)`.
+2. Add `mytype/__init__.py` that does `from .mytype import MyType`.
+3. Add `MyType` to `TEST_TYPES` in `__init__.py` so the CLI exposes it.
+
+Unit tests
+----------
+
+The `unit/` directory holds pytest tests of the framework's own logic (config
+parsing, device-tree rendering, QEMU arg assembly). They do not boot QEMU and
+are independent of the CI smoke tests, but they import the framework, so they
+need the prerequisites above plus `pytest`.
+
+Run from the Xen tree root:
+
+```
+python3 -m pytest automation/scripts/qtb/riscv/unit/
+```


^ permalink raw reply related	[flat|nested] 9+ messages in thread

* [PATCH v2 6/6] CI: run the riscv64 smoke test via QTB framework console-test
  2026-08-27  9:35 [PATCH v2 0/6] automation: add QTB test framework for riscv64 smoke tests Baptiste Le Duc
                   ` (4 preceding siblings ...)
  2026-08-27  9:42 ` [PATCH v2 5/6] automation/qtb: add QTB framework README Baptiste Le Duc
@ 2026-08-27  9:42 ` Baptiste Le Duc
  5 siblings, 0 replies; 9+ messages in thread
From: Baptiste Le Duc @ 2026-08-27  9:42 UTC (permalink / raw)
  To: xen-devel; +Cc: Baptiste Le Duc, Doug Goldstein, Stefano Stabellini

qemu-smoke-riscv64-gcc drove QEMU through
automation/scripts/qemu-smoke-riscv64.sh, an expect wrapper whose machine
description (cpus, memory, device tree, console wiring) lived in the script
itself. The QTB framework now owns all of that: machines come from the
shared catalog, expectations from the test type's own YAML.

Turn .qemu-riscv64 into a template running qemu_smoke_riscv64.py <type> run
<test> in the qtb-riscv64 container, machine and test picked per job
through QTB_TEST_TYPE/QTB_TEST. The container comes from the test-artifacts
registry, hence the new ARTIFACTS_REGISTRY next to the existing
ARTIFACTS_REPO/ARTIFACTS_BRANCH. QTB_BINARIES_DIR points at the artifacts
of the job (Xen only currently, but aims to have initrd and linux images
when dom0less will be supported). QTB_LOG_DIR collects the per-console
logs, kept on failure and on success.

Point qemu-smoke-riscv64-gcc at that template, running the console-test
type on dom0less-1smp-0domu-1vcpu-aplic-imsic-null: a Xen-only machine, so
the smoke check is Xen's own "All set up" on console 0, the same string the
expect script waited for.

Drop automation/scripts/qemu-smoke-riscv64.sh as it has no caller left in
the CI after this patch and drop smoke.serial from the .qemu-riscv64
artifacts since no riscv64 job uses it anymore, the logs are now kept under
QTB_LOG_DIR.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Baptiste Le Duc <baptiste.le-duc@vates.tech>
---
 .gitlab-ci.yml                           |  3 +++
 automation/gitlab-ci/test.yaml           | 20 ++++++++++++++------
 automation/scripts/qemu-smoke-riscv64.sh | 19 -------------------
 3 files changed, 17 insertions(+), 25 deletions(-)
 delete mode 100755 automation/scripts/qemu-smoke-riscv64.sh

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f42a9abeaa..15f93b8634 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -11,6 +11,9 @@ variables:
   ARTIFACTS_BRANCH:
     description: "Branch in test-artifacts to use"
     value: master
+  ARTIFACTS_REGISTRY:
+    description: "Registry holding the test-artifacts containers"
+    value: registry.gitlab.com/xen-project/hardware/test-artifacts
   LINUX_JOB_X86_64:
     description: "Job name in test-artifacts to use for Linux x86_64"
     value: linux-6.6.56-x86_64
diff --git a/automation/gitlab-ci/test.yaml b/automation/gitlab-ci/test.yaml
index 61adc1baff..e9dd147380 100644
--- a/automation/gitlab-ci/test.yaml
+++ b/automation/gitlab-ci/test.yaml
@@ -72,14 +72,21 @@
     TEST_TIMEOUT_OVERRIDE: 120
 
 .qemu-riscv64:
+  image: ${ARTIFACTS_REGISTRY}/${CONTAINER}
   extends: .test-jobs-common
   variables:
-    CONTAINER: debian:13-riscv64
-    LOGFILE: qemu-smoke-riscv64.log
+    CONTAINER: debian:13-qtb-riscv64
+    QTB_LOG_DIR: qtb-logs
+    QTB_BINARIES_DIR: ${CI_PROJECT_DIR}/binaries
+  script:
+    - ./automation/scripts/qemu_smoke_riscv64.py
+      ${QTB_TEST_TYPE}
+      run
+      ${QTB_TEST}
+      --log-dir ${QTB_LOG_DIR}
   artifacts:
     paths:
-      - smoke.serial
-      - '*.log'
+      - ${QTB_LOG_DIR}
     when: always
   tags:
     - x86_64
@@ -779,8 +786,9 @@ qemu-xtf-argo-x86_64-gcc-debug:
 
 qemu-smoke-riscv64-gcc:
   extends: .qemu-riscv64
-  script:
-    - ./automation/scripts/qemu-smoke-riscv64.sh 2>&1 | tee ${LOGFILE}
+  variables:
+    QTB_TEST_TYPE: console-test
+    QTB_TEST: dom0less-1smp-0domu-1vcpu-aplic-imsic-null
   needs:
     - debian-13-riscv64-gcc-debug
 
diff --git a/automation/scripts/qemu-smoke-riscv64.sh b/automation/scripts/qemu-smoke-riscv64.sh
deleted file mode 100755
index c0b1082a08..0000000000
--- a/automation/scripts/qemu-smoke-riscv64.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/bin/bash
-
-set -ex -o pipefail
-
-# Run the test
-rm -f smoke.serial
-
-export TEST_CMD="qemu-system-riscv64 \
-    -M virt,aia=aplic-imsic \
-    -cpu rv64,svpbmt=on \
-    -smp 1 \
-    -nographic \
-    -m 2g \
-    -kernel binaries/xen"
-
-export TEST_LOG="smoke.serial"
-export PASSED="All set up"
-
-./automation/scripts/console.exp |& sed 's/\r\+$//'


^ permalink raw reply related	[flat|nested] 9+ messages in thread

* Re: [PATCH v2 2/6] automation/qtb: add jinja2 device trees for riscv64 smoke tests
  2026-08-27  9:42 ` [PATCH v2 2/6] automation/qtb: add jinja2 device trees for riscv64 smoke tests Baptiste Le Duc
@ 2026-09-10 10:00   ` Zhang Zheng
  2026-09-10 11:28     ` Baptiste Le Duc
  0 siblings, 1 reply; 9+ messages in thread
From: Zhang Zheng @ 2026-09-10 10:00 UTC (permalink / raw)
  To: Baptiste Le Duc; +Cc: xen-devel, Doug Goldstein, Stefano Stabellini

On Thu, Aug 27, 2026 at 11:42:48AM +0200, Baptiste Le Duc wrote:
> The dom0less RISC-V smoke tests need a host device tree describing the
> platform (CPUs, APLIC/IMSIC, uart). It varies per machine (hart count, MMU
> type), so a single static .dts cannot cover the test matrix.
> 
> Add dts/qemu-host.dts.j2, a template of the QEMU virt platform in
> aia=aplic-imsic mode: per-hart cpu/cpu-intc nodes, the M- and S-mode APLIC
> and IMSIC pairs, CLINT and the ns16550a uart. It takes ncpus, mmu_type and
> xen_bootargs as arguments.
> 
> Values QEMU hardcodes are set as named constants matching their source
> symbols (QEMU_UART0_IRQ, QEMU_IRQCHIP_NUM_SOURCES, ...) rather than
> open-coded, so a QEMU-side change is easy to trace.
> 
> The template is inert on its own: the generated dtb will be used in next
> patch.
> 
> Assisted-by: Claude:claude-opus-5
> Signed-off-by: Baptiste Le Duc <baptiste.le-duc@vates.tech>
> ---
>  .../scripts/qtb/riscv/dts/qemu-host.dts.j2    | 160 ++++++++++++++++++
>  1 file changed, 160 insertions(+)
>  create mode 100644 automation/scripts/qtb/riscv/dts/qemu-host.dts.j2
> 
> diff --git a/automation/scripts/qtb/riscv/dts/qemu-host.dts.j2 b/automation/scripts/qtb/riscv/dts/qemu-host.dts.j2
> new file mode 100644
> index 0000000000..13a8e983ce
> --- /dev/null
> +++ b/automation/scripts/qtb/riscv/dts/qemu-host.dts.j2
> @@ -0,0 +1,160 @@
> +/dts-v1/;
> +
> +{#-
> + * Jinja2 QEMU "virt" platform device tree for Xen RISC-V tests.
> + *
> + * Interrupt controller: APLIC in MSI mode + IMSIC
> + * (QEMU -M virt,aia=aplic-imsic).
> + *
> + * Rendered by xen_dt.py.
> + *
> + * Variables:
> + *   ncpus        - number of physical harts                (int, >= 1)
> + *   mmu_type     - Xen host MMU type, e.g. "sv39"          (string)
> + *   xen_bootargs - Xen command line                        (string)
> + *
> + * Per-hart nodes are labelled cpu<i> / cpu<i>_intc and referenced with &label.
> + *
> + * No `aia-guests=N`, so no VS-mode guest files: IMSIC reg size is
> + * ncpus * page size.
> +-#}
> +{#- Values QEMU hardcodes, need to be described to Xen -#}
> +{%- set QEMU_TIMEBASE_FREQUENCY = 10000000 %}   {#- RISCV_ACLINT_DEFAULT_TIMEBASE_FREQ -#}
> +{%- set QEMU_IRQCHIP_NUM_SOURCES = 96 %}        {#- VIRT_IRQCHIP_NUM_SOURCES (virt.h) -#}
> +{%- set QEMU_IRQCHIP_NUM_MSIS = 255 %}          {#- VIRT_IRQCHIP_NUM_MSIS -#}
> +{%- set QEMU_UART_CLOCK_FREQUENCY = 3686400 %}  {#- create_fdt_uart() -#}
> +{%- set QEMU_UART0_IRQ = 10 %}                  {#- UART0_IRQ -#}
> +{%- set QEMU_IMSIC_PAGE_SZ = 0x1000 %}          {#- IMSIC_MMIO_PAGE_SZ -#}
> +
> +{%- set IRQ_TYPE_LEVEL_HIGH = 4 %}
> +{%- set APLIC_IRQ_CELLS = 2 %}
> +
> +{%- set IRQ_M_SOFT = 3 %}
> +{%- set IRQ_M_TIMER = 7 %}
> +{%- set IRQ_S_EXT = 9 %}
> +{%- set IRQ_M_EXT = 11 %}
> +
> +/ {
> +    #address-cells = <0x02>;
> +    #size-cells = <0x02>;
> +    compatible = "riscv-virtio";
> +    model = "riscv-virtio,qemu";
> +
> +    memory@80000000 {
> +        device_type = "memory";
> +        reg = <0x00 0x80000000 0x00 0x80000000>;
> +    };
> +
> +    cpus {
> +        #address-cells = <0x01>;
> +        #size-cells = <0x00>;
> +        timebase-frequency = <{{ QEMU_TIMEBASE_FREQUENCY }}>;
> +{% for i in range(ncpus) %}
> +        cpu{{ i }}: cpu@{{ i }} {
> +            device_type = "cpu";
> +            reg = <0x{{ '%x' % i }}>;
> +            status = "okay";
> +            compatible = "riscv";
> +            riscv,cbop-block-size = <0x40>;
> +            riscv,cboz-block-size = <0x40>;
> +            riscv,cbom-block-size = <0x40>;
> +            riscv,isa = "rv64imafdch_zicntr_zicsr_zifencei_zihintpause_zihpm_zba_zbb_zbs_smstateen_svpbmt_smaia_ssaia";
> +            mmu-type = "riscv,{{ mmu_type }}";
> +
> +            cpu{{ i }}_intc: interrupt-controller@{{ i }} {
> +                #interrupt-cells = <0x01>;
> +                interrupt-controller;
> +                compatible = "riscv,cpu-intc";
> +            };
> +        };
> +{% endfor %}
> +        cpu-map {
> +
> +            cluster0 {
> +{% for i in range(ncpus) %}
> +                core{{ i }} {
> +                    cpu = <&cpu{{ i }}>;
> +                };
> +{% endfor %}
> +            };
> +        };
> +    };
> +
> +    soc {
> +        #address-cells = <0x02>;
> +        #size-cells = <0x02>;
> +        compatible = "simple-bus";
> +        ranges;
> +
> +        serial@10000000 {
> +            interrupts = <{{ QEMU_UART0_IRQ }} {{ IRQ_TYPE_LEVEL_HIGH }}>;
> +            interrupt-parent = <&aplic_s>;
> +            clock-frequency = <{{ QEMU_UART_CLOCK_FREQUENCY }}>;
> +            reg = <0x00 0x10000000 0x00 0x100>;
> +            compatible = "ns16550a";
> +        };
> +
> +        aplic_s: aplic@d000000 {
> +            riscv,num-sources = <{{ QEMU_IRQCHIP_NUM_SOURCES }}>;
> +            reg = <0x00 0xd000000 0x00 0x8000>;
> +            msi-parent = <&imsic_s>;
> +            interrupt-controller;
> +            #interrupt-cells = <{{ APLIC_IRQ_CELLS }}>;
> +            compatible = "riscv,aplic";
> +        };
> +
> +        aplic@c000000 {
> +            riscv,delegate = <&aplic_s 0x01 {{ QEMU_IRQCHIP_NUM_SOURCES }}>;

It seems like `riscv,delegate` was deprecated in QEMU 9.1 and has been removed in QEMU 11.0. The
property defined by the APLIC device-tree binding is `riscv,delegation`.

See:
https://lists.gnu.org/archive/html/qemu-devel/2026-03/msg01459.html

> +            riscv,children = <&aplic_s>;
> +            riscv,num-sources = <{{ QEMU_IRQCHIP_NUM_SOURCES }}>;
> +            reg = <0x00 0xc000000 0x00 0x8000>;
> +            msi-parent = <&imsic_m>;
> +            interrupt-controller;
> +            #interrupt-cells = <{{ APLIC_IRQ_CELLS }}>;
> +            compatible = "riscv,aplic";
> +        };
> +
> +        imsic_s: imsics@28000000 {
> +            riscv,num-ids = <{{ QEMU_IRQCHIP_NUM_MSIS }}>;
> +            reg = <0x00 0x28000000 0x00 0x{{ '%x' % (ncpus * QEMU_IMSIC_PAGE_SZ) }}>;
> +            interrupts-extended = <
> +                {%- for i in range(ncpus) %}
> +                    &cpu{{ i }}_intc {{ IRQ_S_EXT }}
> +                {%- endfor %}
> +            >;
> +            msi-controller;
> +            interrupt-controller;
> +            #interrupt-cells = <0x00>;
> +            compatible = "riscv,imsics";
> +        };
> +
> +        imsic_m: imsics@24000000 {
> +            riscv,num-ids = <{{ QEMU_IRQCHIP_NUM_MSIS }}>;
> +            reg = <0x00 0x24000000 0x00 0x{{ '%x' % (ncpus * QEMU_IMSIC_PAGE_SZ) }}>;
> +            interrupts-extended = <
> +                {%- for i in range(ncpus) %}
> +                    &cpu{{ i }}_intc {{ IRQ_M_EXT }}
> +                {%- endfor %}
> +            >;
> +            msi-controller;
> +            interrupt-controller;
> +            #interrupt-cells = <0x00>;
> +            compatible = "riscv,imsics";
> +        };
> +
> +        clint@2000000 {
> +            interrupts-extended = <
> +                {%- for i in range(ncpus) %}
> +                    &cpu{{ i }}_intc {{ IRQ_M_SOFT }} &cpu{{ i }}_intc {{ IRQ_M_TIMER }}
> +                {%- endfor %}
> +            >;
> +            reg = <0x00 0x2000000 0x00 0x10000>;
> +            compatible = "sifive,clint0", "riscv,clint0";
> +        };
> +    };
> +
> +    chosen {
> +        stdout-path = "/soc/serial@10000000";
> +        xen,xen-bootargs = "{{ xen_bootargs }}";
> +    };
> +};
> 

Zhang



^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [PATCH v2 2/6] automation/qtb: add jinja2 device trees for riscv64 smoke tests
  2026-09-10 10:00   ` Zhang Zheng
@ 2026-09-10 11:28     ` Baptiste Le Duc
  0 siblings, 0 replies; 9+ messages in thread
From: Baptiste Le Duc @ 2026-09-10 11:28 UTC (permalink / raw)
  To: Zhang Zheng
  Cc: Baptiste Le Duc, xen-devel, Doug Goldstein, Stefano Stabellini

On 2026-09-10 18:00 +0800, Zhang Zheng wrote:
> On Thu, Aug 27, 2026 at 11:42:48AM +0200, Baptiste Le Duc wrote:
> > The dom0less RISC-V smoke tests need a host device tree describing the
> > platform (CPUs, APLIC/IMSIC, uart). It varies per machine (hart count, MMU
> > type), so a single static .dts cannot cover the test matrix.
> > 
> > Add dts/qemu-host.dts.j2, a template of the QEMU virt platform in
> > aia=aplic-imsic mode: per-hart cpu/cpu-intc nodes, the M- and S-mode APLIC
> > and IMSIC pairs, CLINT and the ns16550a uart. It takes ncpus, mmu_type and
> > xen_bootargs as arguments.
> > 
> > Values QEMU hardcodes are set as named constants matching their source
> > symbols (QEMU_UART0_IRQ, QEMU_IRQCHIP_NUM_SOURCES, ...) rather than
> > open-coded, so a QEMU-side change is easy to trace.
> > 
> > The template is inert on its own: the generated dtb will be used in next
> > patch.
> > 
> > Assisted-by: Claude:claude-opus-5
> > Signed-off-by: Baptiste Le Duc <baptiste.le-duc@vates.tech>
> > ---
> >  .../scripts/qtb/riscv/dts/qemu-host.dts.j2    | 160 ++++++++++++++++++
> >  1 file changed, 160 insertions(+)
> >  create mode 100644 automation/scripts/qtb/riscv/dts/qemu-host.dts.j2
> > 
> > diff --git a/automation/scripts/qtb/riscv/dts/qemu-host.dts.j2 b/automation/scripts/qtb/riscv/dts/qemu-host.dts.j2
> > new file mode 100644
> > index 0000000000..13a8e983ce
> > --- /dev/null
> > +++ b/automation/scripts/qtb/riscv/dts/qemu-host.dts.j2
> > @@ -0,0 +1,160 @@
> > +/dts-v1/;
> > +
> > +{#-
> > + * Jinja2 QEMU "virt" platform device tree for Xen RISC-V tests.
> > + *
> > + * Interrupt controller: APLIC in MSI mode + IMSIC
> > + * (QEMU -M virt,aia=aplic-imsic).
> > + *
> > + * Rendered by xen_dt.py.
> > + *
> > + * Variables:
> > + *   ncpus        - number of physical harts                (int, >= 1)
> > + *   mmu_type     - Xen host MMU type, e.g. "sv39"          (string)
> > + *   xen_bootargs - Xen command line                        (string)
> > + *
> > + * Per-hart nodes are labelled cpu<i> / cpu<i>_intc and referenced with &label.
> > + *
> > + * No `aia-guests=N`, so no VS-mode guest files: IMSIC reg size is
> > + * ncpus * page size.
> > +-#}
> > +{#- Values QEMU hardcodes, need to be described to Xen -#}
> > +{%- set QEMU_TIMEBASE_FREQUENCY = 10000000 %}   {#- RISCV_ACLINT_DEFAULT_TIMEBASE_FREQ -#}
> > +{%- set QEMU_IRQCHIP_NUM_SOURCES = 96 %}        {#- VIRT_IRQCHIP_NUM_SOURCES (virt.h) -#}
> > +{%- set QEMU_IRQCHIP_NUM_MSIS = 255 %}          {#- VIRT_IRQCHIP_NUM_MSIS -#}
> > +{%- set QEMU_UART_CLOCK_FREQUENCY = 3686400 %}  {#- create_fdt_uart() -#}
> > +{%- set QEMU_UART0_IRQ = 10 %}                  {#- UART0_IRQ -#}
> > +{%- set QEMU_IMSIC_PAGE_SZ = 0x1000 %}          {#- IMSIC_MMIO_PAGE_SZ -#}
> > +
> > +{%- set IRQ_TYPE_LEVEL_HIGH = 4 %}
> > +{%- set APLIC_IRQ_CELLS = 2 %}
> > +
> > +{%- set IRQ_M_SOFT = 3 %}
> > +{%- set IRQ_M_TIMER = 7 %}
> > +{%- set IRQ_S_EXT = 9 %}
> > +{%- set IRQ_M_EXT = 11 %}
> > +
> > +/ {
> > +    #address-cells = <0x02>;
> > +    #size-cells = <0x02>;
> > +    compatible = "riscv-virtio";
> > +    model = "riscv-virtio,qemu";
> > +
> > +    memory@80000000 {
> > +        device_type = "memory";
> > +        reg = <0x00 0x80000000 0x00 0x80000000>;
> > +    };
> > +
> > +    cpus {
> > +        #address-cells = <0x01>;
> > +        #size-cells = <0x00>;
> > +        timebase-frequency = <{{ QEMU_TIMEBASE_FREQUENCY }}>;
> > +{% for i in range(ncpus) %}
> > +        cpu{{ i }}: cpu@{{ i }} {
> > +            device_type = "cpu";
> > +            reg = <0x{{ '%x' % i }}>;
> > +            status = "okay";
> > +            compatible = "riscv";
> > +            riscv,cbop-block-size = <0x40>;
> > +            riscv,cboz-block-size = <0x40>;
> > +            riscv,cbom-block-size = <0x40>;
> > +            riscv,isa = "rv64imafdch_zicntr_zicsr_zifencei_zihintpause_zihpm_zba_zbb_zbs_smstateen_svpbmt_smaia_ssaia";
> > +            mmu-type = "riscv,{{ mmu_type }}";
> > +
> > +            cpu{{ i }}_intc: interrupt-controller@{{ i }} {
> > +                #interrupt-cells = <0x01>;
> > +                interrupt-controller;
> > +                compatible = "riscv,cpu-intc";
> > +            };
> > +        };
> > +{% endfor %}
> > +        cpu-map {
> > +
> > +            cluster0 {
> > +{% for i in range(ncpus) %}
> > +                core{{ i }} {
> > +                    cpu = <&cpu{{ i }}>;
> > +                };
> > +{% endfor %}
> > +            };
> > +        };
> > +    };
> > +
> > +    soc {
> > +        #address-cells = <0x02>;
> > +        #size-cells = <0x02>;
> > +        compatible = "simple-bus";
> > +        ranges;
> > +
> > +        serial@10000000 {
> > +            interrupts = <{{ QEMU_UART0_IRQ }} {{ IRQ_TYPE_LEVEL_HIGH }}>;
> > +            interrupt-parent = <&aplic_s>;
> > +            clock-frequency = <{{ QEMU_UART_CLOCK_FREQUENCY }}>;
> > +            reg = <0x00 0x10000000 0x00 0x100>;
> > +            compatible = "ns16550a";
> > +        };
> > +
> > +        aplic_s: aplic@d000000 {
> > +            riscv,num-sources = <{{ QEMU_IRQCHIP_NUM_SOURCES }}>;
> > +            reg = <0x00 0xd000000 0x00 0x8000>;
> > +            msi-parent = <&imsic_s>;
> > +            interrupt-controller;
> > +            #interrupt-cells = <{{ APLIC_IRQ_CELLS }}>;
> > +            compatible = "riscv,aplic";
> > +        };
> > +
> > +        aplic@c000000 {
> > +            riscv,delegate = <&aplic_s 0x01 {{ QEMU_IRQCHIP_NUM_SOURCES }}>;
> 
> It seems like `riscv,delegate` was deprecated in QEMU 9.1 and has been removed in QEMU 11.0. The
> property defined by the APLIC device-tree binding is `riscv,delegation`.
Thanks for this catch, it seems `riscv,delegate` was kept as an alias
until its entire removal in QEMU 11.0.

I will change it to `riscv,delegation` in v3.
> 
> See:
> https://lists.gnu.org/archive/html/qemu-devel/2026-03/msg01459.html
> 
> > +            riscv,children = <&aplic_s>;
> > +            riscv,num-sources = <{{ QEMU_IRQCHIP_NUM_SOURCES }}>;
> > +            reg = <0x00 0xc000000 0x00 0x8000>;
> > +            msi-parent = <&imsic_m>;
> > +            interrupt-controller;
> > +            #interrupt-cells = <{{ APLIC_IRQ_CELLS }}>;
> > +            compatible = "riscv,aplic";
> > +        };
> > +
> > +        imsic_s: imsics@28000000 {
> > +            riscv,num-ids = <{{ QEMU_IRQCHIP_NUM_MSIS }}>;
> > +            reg = <0x00 0x28000000 0x00 0x{{ '%x' % (ncpus * QEMU_IMSIC_PAGE_SZ) }}>;
> > +            interrupts-extended = <
> > +                {%- for i in range(ncpus) %}
> > +                    &cpu{{ i }}_intc {{ IRQ_S_EXT }}
> > +                {%- endfor %}
> > +            >;
> > +            msi-controller;
> > +            interrupt-controller;
> > +            #interrupt-cells = <0x00>;
> > +            compatible = "riscv,imsics";
> > +        };
> > +
> > +        imsic_m: imsics@24000000 {
> > +            riscv,num-ids = <{{ QEMU_IRQCHIP_NUM_MSIS }}>;
> > +            reg = <0x00 0x24000000 0x00 0x{{ '%x' % (ncpus * QEMU_IMSIC_PAGE_SZ) }}>;
> > +            interrupts-extended = <
> > +                {%- for i in range(ncpus) %}
> > +                    &cpu{{ i }}_intc {{ IRQ_M_EXT }}
> > +                {%- endfor %}
> > +            >;
> > +            msi-controller;
> > +            interrupt-controller;
> > +            #interrupt-cells = <0x00>;
> > +            compatible = "riscv,imsics";
> > +        };
> > +
> > +        clint@2000000 {
> > +            interrupts-extended = <
> > +                {%- for i in range(ncpus) %}
> > +                    &cpu{{ i }}_intc {{ IRQ_M_SOFT }} &cpu{{ i }}_intc {{ IRQ_M_TIMER }}
> > +                {%- endfor %}
> > +            >;
> > +            reg = <0x00 0x2000000 0x00 0x10000>;
> > +            compatible = "sifive,clint0", "riscv,clint0";
> > +        };
> > +    };
> > +
> > +    chosen {
> > +        stdout-path = "/soc/serial@10000000";
> > +        xen,xen-bootargs = "{{ xen_bootargs }}";
> > +    };
> > +};
> > 
> 
> Zhang
> 
> 
> 
> 




^ permalink raw reply	[flat|nested] 9+ messages in thread

end of thread, other threads:[~2026-09-10 11:28 UTC | newest]

Thread overview: 9+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-27  9:35 [PATCH v2 0/6] automation: add QTB test framework for riscv64 smoke tests Baptiste Le Duc
2026-08-27  9:42 ` [PATCH v2 1/6] Add a QTB container to run the Xen riscv64 tests Baptiste Le Duc
2026-08-27  9:42 ` [PATCH v2 2/6] automation/qtb: add jinja2 device trees for riscv64 smoke tests Baptiste Le Duc
2026-09-10 10:00   ` Zhang Zheng
2026-09-10 11:28     ` Baptiste Le Duc
2026-08-27  9:42 ` [PATCH v2 3/6] automation/qtb: add Python QTB framework with the console-test type Baptiste Le Duc
2026-08-27  9:42 ` [PATCH v2 4/6] automation/qtb: add unit tests for the QTB framework Baptiste Le Duc
2026-08-27  9:42 ` [PATCH v2 5/6] automation/qtb: add QTB framework README Baptiste Le Duc
2026-08-27  9:42 ` [PATCH v2 6/6] CI: run the riscv64 smoke test via QTB framework console-test Baptiste Le Duc

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.