erplibre/test/test_qemu_gpu.py
Mathieu Benoit a55b03e199 [ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.

Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.

Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.

--- EN ---

A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.

An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.

Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00

561 lines
23 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""GPU de l'hôte : détection, déploiement, et réglage d'une VM éteinte.
Une VM graphique sans accélération rend tout par le processeur — le bureau
comme l'émulateur Android qui tourne dedans. L'hôte a un GPU ou non ; s'il en
a un, la VM doit le prendre, et c'est le défaut.
Ce que ces tests gardent, appris en le cassant :
- UN SEUL « --video » : la 3D remplace le « --video virtio », elle ne s'y
ajoute pas — deux écrans, et l'invité n'en peuple qu'un.
- « --add-device --graphics egl-headless » n'est PAS idempotent : deux appels,
deux affichages. D'où l'état lu avant tout plan.
- « virt-xml --memory N » ne touche que <currentMemory> : la RAM plafonnait en
silence à l'ancien maximum.
- Le retrait de la 3D cible le TYPE egl-headless : sans ce ciblage, c'est la
console VNC de la VM qui disparaît.
- « --define » sur chaque commande : sans lui, virt-xml POSE UNE QUESTION et
le menu se bloque sans rien dire.
"""
import contextlib
import importlib.util
import io
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.argv = ["todo.py"]
from script.todo import qemu_hardware as hw # noqa: E402
from script.todo.todo import TODO # noqa: E402
def _deploy_qemu():
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
path = Path(__file__).resolve().parents[1] / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
# XML réel d'une VM du parc, réduit à ce qui décide du matériel.
XML_SANS_3D = """<domain type='kvm'>
<name>erplibre-ubuntu-2604-gnome</name>
<memory unit='KiB'>33554432</memory>
<currentMemory unit='KiB'>33554432</currentMemory>
<vcpu placement='static'>8</vcpu>
<devices>
<graphics type='vnc' port='5900' autoport='yes' listen='127.0.0.1'>
<listen type='address' address='127.0.0.1'/>
</graphics>
<video>
<model type='virtio' heads='1' primary='yes'/>
</video>
</devices>
</domain>
"""
XML_AVEC_3D = XML_SANS_3D.replace(
"<model type='virtio' heads='1' primary='yes'/>",
"<model type='virtio' heads='1' primary='yes'>"
"<acceleration accel3d='yes'/></model>",
).replace(
"</video>",
"</video>\n <graphics type='egl-headless'>"
"<gl rendernode='/dev/dri/renderD128'/></graphics>",
)
XML_SERVEUR = """<domain type='kvm'>
<name>erplibre-serveur</name>
<memory unit='KiB'>2097152</memory>
<vcpu placement='static'>2</vcpu>
<devices/>
</domain>
"""
NODE = "/dev/dri/renderD128"
class TestDetection(unittest.TestCase):
"""Le GPU se lit dans /dev/dri, pas dans une liste de cartes connues."""
def _dri(self, *names):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
for n in names:
Path(tmp.name, n).touch()
return tmp.name
def test_it_keeps_render_nodes_and_ignores_the_rest(self):
"""« card0 » est le nœud d'affichage, pas de rendu : QEMU ouvre
renderD*, et lui donner card0 échouerait au démarrage."""
found = DQ.host_render_nodes(
self._dri("card0", "renderD128", "by-path")
)
self.assertEqual(1, len(found))
self.assertTrue(found[0].endswith("renderD128"))
def test_several_cards_come_out_sorted(self):
found = DQ.host_render_nodes(self._dri("renderD129", "renderD128"))
self.assertEqual(
["renderD128", "renderD129"], [Path(f).name for f in found]
)
def test_a_host_without_dri_answers_empty(self):
"""Cet hyperviseur-ci est lui-même une VM sans GPU : /dev/dri n'existe
pas du tout. Une exception ici ferait échouer TOUT déploiement."""
self.assertEqual([], DQ.host_render_nodes("/nexistepas/dri"))
self.assertEqual("", DQ.host_gpu_node("/nexistepas/dri"))
def test_presence_is_the_test_not_our_own_access(self):
"""Le nœud appartient au groupe « render » ; libvirt donne l'accès au
démarrage du domaine. Tester nos droits rejetterait un hôte valable."""
d = self._dri("renderD128")
os.chmod(Path(d, "renderD128"), 0o000)
self.assertTrue(DQ.host_gpu_node(d).endswith("renderD128"))
class TestDecision(unittest.TestCase):
"""« Par défaut avec GPU s'il existe » — et le silence n'est pas permis."""
def test_auto_takes_the_gpu_when_there_is_one(self):
on, msg = DQ.gpu_decision("auto", NODE, True)
self.assertTrue(on)
self.assertIn(NODE, msg)
def test_auto_without_gpu_says_why_it_falls_back(self):
"""Sans ce message, une VM en rendu logiciel ne s'explique pas — et on
cherche la lenteur ailleurs pendant des heures."""
on, msg = DQ.gpu_decision("auto", "", True)
self.assertFalse(on)
self.assertTrue(msg.strip())
def test_forcing_it_without_a_node_refuses_and_warns(self):
"""libvirt refuse de démarrer un domaine dont le rendernode manque :
obéir aveuglément à « --gpu on » livrerait une VM qui ne démarre pas.
"""
on, msg = DQ.gpu_decision("on", "", True)
self.assertFalse(on)
self.assertIn("⚠", msg)
def test_off_stays_off_and_silent(self):
self.assertEqual((False, ""), DQ.gpu_decision("off", NODE, True))
def test_no_screen_no_3d(self):
"""Une VM serveur n'a pas d'écran : la 3D n'y accélérerait rien, et le
« --edit --video » échouerait faute de périphérique vidéo."""
on, _ = DQ.gpu_decision("auto", NODE, False)
self.assertFalse(on)
class TestDeployArgs(unittest.TestCase):
def test_the_args_carry_both_halves(self):
"""L'accélération sur le virtio-gpu ET un affichage capable de
contexte GL : l'une sans l'autre ne donne aucune 3D."""
args = DQ.gpu_install_args(NODE)
self.assertIn("model.acceleration.accel3d=on", " ".join(args))
self.assertIn(f"gl.rendernode={NODE}", " ".join(args))
def test_only_one_video_device_survives(self):
"""Le bug qui donne deux écrans : garder « --video virtio » à côté du
« --video » de la 3D. L'invité n'en peuple alors qu'un."""
video, gpu_args, _ = DQ.gpu_apply(
["--video", "virtio"], "auto", NODE, True
)
self.assertEqual([], video)
self.assertEqual(1, (video + gpu_args).count("--video"))
def test_without_gpu_the_plain_video_stays(self):
video, gpu_args, _ = DQ.gpu_apply(
["--video", "virtio"], "auto", "", True
)
self.assertEqual(["--video", "virtio"], video)
self.assertEqual([], gpu_args)
def test_the_egl_display_is_added_beside_vnc_not_instead(self):
"""« egl-headless » n'ouvre aucun port : il ne remplace pas la console
VNC, il porte le contexte OpenGL. Les deux cohabitent."""
args = DQ.gpu_install_args(NODE)
self.assertIn("type=egl-headless", " ".join(args))
self.assertNotIn("vnc", " ".join(args))
class TestReadState(unittest.TestCase):
def test_it_reads_what_the_vm_has(self):
st = hw.hw_state(XML_SANS_3D)
self.assertEqual("erplibre-ubuntu-2604-gnome", st["name"])
self.assertEqual(8, st["vcpus"])
self.assertEqual(32768, st["mem_mib"])
self.assertEqual("virtio", st["video"])
self.assertFalse(st["accel3d"])
self.assertTrue(st["screen"])
def test_it_sees_an_existing_3d_setup(self):
st = hw.hw_state(XML_AVEC_3D)
self.assertTrue(st["accel3d"])
self.assertTrue(st["egl"])
self.assertEqual(NODE, st["render"])
def test_egl_headless_alone_is_not_a_screen(self):
"""Il n'affiche rien et n'ouvre aucun port. Le compter comme écran
proposerait la 3D à une VM qui n'a rien à accélérer."""
xml = XML_SERVEUR.replace(
"<devices/>",
"<devices><graphics type='egl-headless'/></devices>",
)
self.assertFalse(hw.hw_state(xml)["screen"])
def test_the_balloon_target_is_what_the_vm_gets(self):
"""<memory> est le maximum, <currentMemory> ce que la VM voit. Lire le
premier annoncerait 32 Go à une VM qui en a 4."""
xml = XML_SANS_3D.replace(
"<currentMemory unit='KiB'>33554432</currentMemory>",
"<currentMemory unit='KiB'>4194304</currentMemory>",
)
self.assertEqual(4096, hw.hw_state(xml)["mem_mib"])
def test_units_are_exact(self):
"""KB vaut mille octets, KiB en vaut 1024 : le schéma libvirt autorise
les deux, et « à peu près » se voit dans le tableau."""
for unit, value, mib in (
("KiB", 1048576, 1024),
("MiB", 2048, 2048),
("GiB", 4, 4096),
("bytes", 1073741824, 1024),
):
xml = XML_SERVEUR.replace(
"<memory unit='KiB'>2097152</memory>",
f"<memory unit='{unit}'>{value}</memory>",
)
self.assertEqual(mib, hw.hw_state(xml)["mem_mib"], unit)
def test_broken_xml_gives_an_empty_state(self):
st = hw.hw_state("<domain")
self.assertEqual("", st["name"])
self.assertEqual(0, st["vcpus"])
def test_autostart_comes_from_outside_the_xml(self):
"""Il vit dans un lien symbolique de libvirt, pas dans la définition."""
self.assertTrue(hw.hw_state(XML_SANS_3D, autostart=True)["autostart"])
class TestPlan(unittest.TestCase):
def _plan(self, xml, want, node=NODE, autostart=False):
return hw.hw_plan(hw.hw_state(xml, autostart), want, node)
def test_nothing_wanted_nothing_planned(self):
self.assertEqual([], self._plan(XML_SANS_3D, {}))
def test_the_same_values_change_nothing(self):
"""Revalider le formulaire sans rien toucher ne doit RIEN lancer."""
plan = self._plan(
XML_SANS_3D, {"vcpus": 8, "ram": 32768, "gpu": False}
)
self.assertEqual([], plan)
def test_3d_already_on_the_same_node_is_not_added_twice(self):
"""« --add-device --graphics egl-headless » deux fois pose DEUX
affichages : c'est l'état lu qui l'empêche, pas virt-xml."""
self.assertEqual([], self._plan(XML_AVEC_3D, {"gpu": True}))
def test_another_node_is_edited_in_place(self):
plan = self._plan(
XML_AVEC_3D, {"gpu": True}, node="/dev/dri/renderD129"
)
joined = [" ".join(e["cmd"]) for e in plan]
self.assertEqual(1, len(plan))
self.assertIn("--edit type=egl-headless", joined[0])
self.assertNotIn("--add-device", joined[0])
def test_turning_3d_on_carries_both_halves(self):
plan = self._plan(XML_SANS_3D, {"gpu": True})
joined = " ".join(" ".join(e["cmd"]) for e in plan)
self.assertIn("model.acceleration.accel3d=on", joined)
self.assertIn(f"gl.rendernode={NODE}", joined)
def test_turning_3d_off_targets_the_type_so_vnc_survives(self):
"""Un « --remove-device --graphics » sans cible emporterait la console
VNC : plus aucun accès à l'écran de la VM."""
plan = self._plan(XML_AVEC_3D, {"gpu": False})
removals = [e for e in plan if "--remove-device" in e["cmd"]]
self.assertEqual(1, len(removals))
self.assertIn("type=egl-headless", removals[0]["cmd"])
def test_ram_sets_the_maximum_too(self):
"""« --memory N » seul ne touche que <currentMemory> : la VM plafonne
à son ancien maximum, sans un mot."""
plan = self._plan(XML_SANS_3D, {"ram": 65536})
arg = [c for c in plan[0]["cmd"] if c.startswith("memory=")][0]
self.assertIn("memory=65536", arg)
self.assertIn("currentMemory=65536", arg)
def test_3d_without_a_host_node_is_refused_with_a_reason(self):
plan = hw.hw_plan(hw.hw_state(XML_SANS_3D), {"gpu": True}, "")
self.assertEqual(1, len(plan))
self.assertNotIn("cmd", plan[0])
self.assertTrue(plan[0]["skip"])
def test_3d_on_a_screenless_vm_is_refused(self):
"""« --edit --video » échouerait : il n'y a pas de périphérique vidéo
à modifier. Mieux vaut le dire que laisser virt-xml protester."""
plan = self._plan(XML_SERVEUR, {"gpu": True})
self.assertEqual(1, len(plan))
self.assertIn("skip", plan[0])
def test_autostart_only_moves_when_it_differs(self):
self.assertEqual(
[], self._plan(XML_SANS_3D, {"autostart": True}, autostart=True)
)
plan = self._plan(XML_SANS_3D, {"autostart": False}, autostart=True)
self.assertIn("--disable", plan[0]["cmd"])
def test_every_command_defines_and_names_its_uri(self):
"""Sans « --define », virt-xml INTERROGE l'utilisateur quand le domaine
tourne : le menu se bloque sur une question qu'on ne voit pas. Sans
« --connect », un appel non root viserait qemu:///session, où les VM du
parc n'existent pas."""
plan = self._plan(XML_SANS_3D, {"vcpus": 4, "ram": 8192, "gpu": True})
self.assertTrue(plan)
for entry in plan:
self.assertIn("--define", entry["cmd"])
self.assertIn("qemu:///system", entry["cmd"])
def test_a_state_without_a_name_plans_nothing(self):
"""Un dumpxml illisible ne doit pas produire une commande sans cible."""
self.assertEqual([], hw.hw_plan({}, {"vcpus": 4}, NODE))
class TestWant(unittest.TestCase):
def test_empty_fields_keep_the_current_values(self):
"""Valider sans rien saisir ne doit pas rétrécir la VM à néant."""
st = hw.hw_state(XML_SANS_3D)
want = hw.build_want(st, "", "", False, False)
self.assertEqual(8, want["vcpus"])
self.assertEqual(32768, want["ram"])
def test_gigabytes_are_understood(self):
st = hw.hw_state(XML_SANS_3D)
self.assertEqual(8192, hw.build_want(st, "", "8G", 0, 0)["ram"])
def test_nonsense_does_not_shrink_the_vm(self):
st = hw.hw_state(XML_SANS_3D)
want = hw.build_want(st, "beaucoup", "gros", 0, 0)
self.assertEqual(8, want["vcpus"])
self.assertEqual(32768, want["ram"])
class TestDisplay(unittest.TestCase):
def test_the_ram_field_stays_short_enough_to_read(self):
"""« 32768 » ne tient pas dans le champ et s'affichait « 3276 » : un
nombre tronqué qu'on valide sans regarder rétrécit la machine."""
self.assertEqual("32G", hw.ram_field(32768))
self.assertEqual("1536", hw.ram_field(1536))
for mib in (1024, 12288, 32768, 65536):
self.assertLessEqual(len(hw.ram_field(mib)), 4)
def test_sizes_read_like_sizes(self):
self.assertEqual("1 Go", hw.fmt_mib(1024))
self.assertEqual("1,5 Go", hw.fmt_mib(1536))
self.assertEqual("512 Mo", hw.fmt_mib(512))
def test_the_summary_names_the_render_node(self):
self.assertIn("renderD128", hw.hw_summary(hw.hw_state(XML_AVEC_3D)))
def test_a_screen_without_3d_says_software_rendering(self):
summary = hw.hw_summary(hw.hw_state(XML_SANS_3D))
self.assertIn("8 vCPU", summary)
self.assertIn(hw.t("software rendering"), summary)
class TestMenuGlue(unittest.TestCase):
"""Le raccord dans todo.py : ce qui est éteint, et ce qui s'exécute."""
def _todo(self, states, xml=XML_SANS_3D, node=NODE):
todo = TODO.__new__(TODO)
todo._qemu_domstate = lambda name: states[name]
todo._qemu_dumpxml = lambda name: xml.replace(
"erplibre-ubuntu-2604-gnome", name
)
todo._qemu_autostart = lambda name: False
todo._qemu_host_gpu_node = lambda: node
todo.launched = []
todo.execute = mock.Mock()
todo.execute.exec_command_live = (
lambda cmd, **kw: todo.launched.append(cmd)
)
return todo
def _run(self, todo, names, answers):
it = iter(answers)
out = io.StringIO()
with mock.patch("builtins.input", lambda *a: next(it, "")):
with contextlib.redirect_stdout(out):
todo._qemu_adjust_hardware(names)
return out.getvalue()
def test_a_running_vm_is_left_alone_and_said_so(self):
"""virt-xml y écrirait une définition qui ne prend effet qu'au
prochain démarrage : un réglage qui paraît appliqué et ne l'est pas."""
todo = self._todo({"vm-a": "running"})
todo._qemu_hw_form = lambda rows, node: {}
out = self._run(todo, ["vm-a"], [])
self.assertIn("vm-a", out)
self.assertEqual([], todo.launched)
def test_a_shut_off_vm_is_adjusted(self):
todo = self._todo({"vm-a": "shut off"})
todo._qemu_hw_form = lambda rows, node: {
"vm-a": {"vcpus": 4, "ram": 8192, "gpu": True}
}
self._run(todo, ["vm-a"], ["o"])
joined = " ".join(todo.launched)
self.assertIn("--vcpus 4", joined)
self.assertIn("accel3d=on", joined)
self.assertTrue(all(c.startswith("sudo ") for c in todo.launched))
def test_nothing_to_change_launches_nothing(self):
todo = self._todo({"vm-a": "shut off"})
todo._qemu_hw_form = lambda rows, node: {
"vm-a": {"vcpus": 8, "ram": 32768, "gpu": False}
}
out = self._run(todo, ["vm-a"], [])
self.assertEqual([], todo.launched)
self.assertIn(hw.t("Nothing to change."), out)
def test_refusing_the_confirmation_launches_nothing(self):
todo = self._todo({"vm-a": "shut off"})
todo._qemu_hw_form = lambda rows, node: {"vm-a": {"vcpus": 4}}
self._run(todo, ["vm-a"], ["n"])
self.assertEqual([], todo.launched)
def test_cancelling_the_form_launches_nothing(self):
todo = self._todo({"vm-a": "shut off"})
todo._qemu_hw_form = lambda rows, node: None
self._run(todo, ["vm-a"], [])
self.assertEqual([], todo.launched)
def test_the_skipped_3d_is_explained_not_silent(self):
todo = self._todo({"vm-a": "shut off"}, node="")
todo._qemu_hw_form = lambda rows, node: {"vm-a": {"gpu": True}}
out = self._run(todo, ["vm-a"], [])
self.assertIn(hw.t("no render node on the host"), out)
self.assertEqual([], todo.launched)
def test_the_host_gpu_is_announced_before_anything_else(self):
todo = self._todo({"vm-a": "shut off"})
todo._qemu_hw_form = lambda rows, node: None
out = self._run(todo, ["vm-a"], [])
self.assertIn(NODE, out)
def test_the_prompts_take_over_when_textual_is_absent(self):
"""Le repli en ligne n'est pas décoratif : sans Textual, c'est la SEULE
voie, et un {} mal interprété annulerait tout."""
todo = self._todo({"vm-a": "shut off"})
todo._qemu_hw_form = lambda rows, node: {}
self._run(todo, ["vm-a"], ["6", "", "o", "n", "o"])
self.assertIn("--vcpus 6", " ".join(todo.launched))
def test_an_empty_answer_keeps_the_current_state(self):
"""Le défaut d'une question fermée est l'état ACTUEL de la VM : sur un
formulaire de matériel, le silence ne modifie rien."""
todo = TODO.__new__(TODO)
with mock.patch("builtins.input", lambda *a: ""):
self.assertTrue(todo._qemu_ask_bool("? ", True))
self.assertFalse(todo._qemu_ask_bool("? ", False))
with mock.patch("builtins.input", lambda *a: "n'importe quoi"):
self.assertTrue(todo._qemu_ask_bool("? ", True))
def test_autostart_is_read_from_virsh(self):
out = "Id: -\nName: vm-a\nAutostart: enable\n"
with mock.patch(
"subprocess.run",
return_value=subprocess.CompletedProcess([], 0, out, ""),
):
self.assertTrue(TODO._qemu_autostart("vm-a"))
with mock.patch("subprocess.run", side_effect=OSError):
self.assertFalse(TODO._qemu_autostart("vm-a"))
class TestForm(unittest.IsolatedAsyncioTestCase):
"""Le formulaire monté pour de vrai : ce qu'il propose et ce qu'il rend."""
async def _mount(self, rows, node):
app = hw.run_hardware_form(rows, node, run_app=False)
return app
async def test_it_returns_the_intention_on_apply(self):
from textual.widgets import Checkbox, Input
app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE)
async with app.run_test() as pilot:
await pilot.pause()
app.query_one("#vcpus0", Input).value = "6"
app.query_one("#ram0", Input).value = "8G"
app.query_one("#gpu0", Checkbox).value = True
await pilot.press("ctrl+s")
await pilot.pause()
want = app.want["erplibre-ubuntu-2604-gnome"]
self.assertEqual(6, want["vcpus"])
self.assertEqual(8192, want["ram"])
self.assertTrue(want["gpu"])
async def test_escape_returns_nothing(self):
app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE)
async with app.run_test() as pilot:
await pilot.press("escape")
await pilot.pause()
self.assertIsNone(app.want)
async def test_3d_is_out_of_reach_without_a_host_node(self):
"""Cocher une case qui ne peut rien produire ferait attendre une
accélération que l'hôte ne sait pas donner."""
from textual.widgets import Checkbox
app = await self._mount([hw.hw_state(XML_SANS_3D)], "")
async with app.run_test() as pilot:
await pilot.pause()
self.assertTrue(app.query_one("#gpu0", Checkbox).disabled)
async def test_3d_is_out_of_reach_for_a_screenless_vm(self):
from textual.widgets import Checkbox
app = await self._mount([hw.hw_state(XML_SERVEUR)], NODE)
async with app.run_test() as pilot:
await pilot.pause()
self.assertTrue(app.query_one("#gpu0", Checkbox).disabled)
async def test_the_fields_start_on_the_current_values(self):
from textual.widgets import Input
app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE)
async with app.run_test() as pilot:
await pilot.pause()
self.assertEqual("8", app.query_one("#vcpus0", Input).value)
self.assertEqual("32G", app.query_one("#ram0", Input).value)
async def test_it_fits_in_eighty_columns(self):
"""Un terminal de 80 colonnes est le plus petit qu'on rencontre ;
au-delà, les libellés se tronquent en « Démarrage automatiq… »."""
import re
rows = [hw.hw_state(XML_SANS_3D), hw.hw_state(XML_SERVEUR)]
app = await self._mount(rows, NODE)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
svg = app.export_screenshot()
texte = " ".join(re.findall(r">([^<>]+)</text>", svg))
self.assertNotIn("…", texte)
if __name__ == "__main__":
unittest.main(verbosity=1)