Module bonus. La mission « QA Engineer d’une flotte GenAI multi-tenant » s’achève au module 05. Ce 6ᵉ module est une extension : il certifie les nouveautés de la ligne v0.10 d’Open WebUI (juin–juillet 2026), la plus grosse évolution depuis la v0.8.
Ce notebook est la couche pédagogique du banc de tests 06-nouveautes.spec.ts. Il n’exécute aucun test navigateur : il lit le spec, explique les concepts nouveaux, et propose des exercices Python exécutables tels quels. Pour lancer réellement les tests, voir la section « Exécuter ce module » du README.
La v0.10 transforme la plateforme en environnement agentique : mémoire repensée, dossiers d’équipe partageables, raisonnement affiché en direct, compaction automatique des longues conversations.
1. Objectifs & place dans la mission
Pré-requis : les modules 03 (chat & streaming) et 05 (API testing). Ce module réutilise les deux familles de tests déjà vues :
API (rapide, déterministe) — APIRequestContext, sans navigateur ;
UI (rendu et interaction) — navigateur, pour ce qui ne se voit qu’à l’écran.
À la fin, tu sauras écrire des tests pour des surfaces récentes et mouvantes :
tester une mémoire qui s’injecte ailleurs, sans polluer l’instance (isolation par nettoyage) ;
prouver une version par la présence d’un champ (type) — feature detection ;
gérer un contenu qui n’existe pas toujours (le raisonnement streamé) par un skip propre ;
certifier la compaction par son comportement observable, sans saturer le contexte.
#
Fonctionnalité
Version
Famille
Bloc
1-2
Mémoire (refonte, champ type)
v0.10.0
API
06a
3-4
Dossiers (CRUD)
v0.10.0
API
06a
5
Dossiers d’équipe (partage)
v0.10.0
API
06a (exercice, 2ᵉ compte)
6
Raisonnement streamé
v0.10.2
UI
06b
7
Compaction automatique
v0.10.0
UI
06b
# --- Setup : localiser le module 06 SANS exposer de chemin absolu ---from pathlib import Pathimport redef _redact(texte: str) ->str:"""Anti-fuite : ne jamais afficher de chemin absolu (home, lettre de lecteur).""" out =str(texte).replace(str(Path.home()), "…")# neutralise tout chemin absolu Windows (C:\...) ou POSIX (/home/...) résiduel out = re.sub(r"[A-Za-z]:[\\/][^\s'\"]+", "…", out) out = re.sub(r"(?<![\w])/(?:home|Users|mnt)[^\s'\"]+", "…", out)return out# Le notebook vit dans 06-nouveautes-v0.10/ ; on retrouve le module par son spec.MODULE =Nonefor base in (Path.cwd(), *Path.cwd().parents):if (base /"06-nouveautes.spec.ts").exists(): MODULE = basebreakif (base /"06-nouveautes-v0.10"/"06-nouveautes.spec.ts").exists(): MODULE = base /"06-nouveautes-v0.10"breakif MODULE isNone: MODULE = Path.cwd() # repli : exécuté hors du dossier du moduleSPEC = MODULE /"06-nouveautes.spec.ts"SERIE = MODULE.parentprint("Module :", _redact(MODULE.name))print("Spec :", SPEC.name, "—", "présent"if SPEC.exists() else"ABSENT (ouvre le notebook depuis 06-nouveautes-v0.10/)")print("Série :", _redact(SERIE.name))
2. Concept — tester une donnée qui s’injecte ailleurs (la mémoire)
La mémoire est particulière : un souvenir créé est ensuite injecté dans les prompts des conversations. Un test qui crée un souvenir et l’oublie pollue les vraies conversations de l’instance. D’où la règle de ce module :
Tout test d’écriture nettoie derrière lui. On utilise un bloc finally pour garantir la suppression, même si une assertion échoue avant.
C’est un pattern général en tests d’intégration : isolation par nettoyage. On marque aussi chaque objet créé d’un tag unique ([pw-module06]) pour le repérer si un nettoyage échoue.
# --- Lire le banc de tests et en extraire la structure, SANS le lancer ---def _titres(mot_cle, source):# 1er argument (chaîne) de describe()/test() : on capture jusqu'au guillemet# suivi d'une virgule — robuste aux apostrophes internes (ex. 'dossiers# d\'equipe'), puis on dé-échappe les quotes. motif = mot_cle +r"\s*['\"](.*?)['\"]\s*,"return [t.replace("\\'", "'").replace('\\"', '"')for t in re.findall(motif, source)]if SPEC.exists(): texte = SPEC.read_text(encoding="utf-8") describes = _titres(r"describe\(", texte) titres = _titres(r"\btest\(", texte)print(f"{len(describes)} bloc(s) describe, {len(titres)} test(s) déclaré(s) :\n")for d in describes:print(" ▸", d)print()for t in titres:print(" -", t)else:print("Spec introuvable — voir le message de la cellule Setup.")
3. Concept — feature detection vs assertion stricte
La refonte mémoire v0.10 ajoute un champ type (ex. "context") à chaque souvenir. On s’en sert comme preuve de version :
expect(created.type,'le champ "type" (v0.10) doit être présent').toBeTruthy();
Sur une instance antérieure à v0.10, ce champ serait absent : le test le détecterait. C’est une façon de documenter une différence de version dans le test lui-même — plus robuste que de coder en dur « on est en 0.10 ».
# --- Démonstration : deviner la version à partir de la forme de la donnée ---def version_probable(souvenir: dict) ->str:# Le champ `type` apparaît avec la refonte mémoire v0.10.return"v0.10+"if souvenir.get("type") else"antérieure à v0.10"exemples = [ {"id": "a1", "content": "aime les schémas", "type": "context"}, # v0.10 {"id": "b2", "content": "note libre"}, # ancien]for ex in exemples:print(f"{ex} -> {version_probable(ex)}")
{'id': 'a1', 'content': 'aime les schémas', 'type': 'context'} -> v0.10+
{'id': 'b2', 'content': 'note libre'} -> antérieure à v0.10
4. Concept — le raisonnement streamé : un contenu qui n’existe pas toujours
Depuis v0.10.2, les modèles « thinking » affichent leurs étapes de raisonnement en direct (bloc repliable au-dessus de la réponse). Mais tous les modèles n’en émettent pas, et l’instance peut ne pas en avoir de configuré.
Un bon test ne doit donc pas échouer sur l’absence de raisonnement : il skip proprement si la fonctionnalité n’est pas exercée. C’est la différence entre « le test a échoué » (régression réelle) et « la pré-condition n’était pas réunie » (rien à conclure). Confondre les deux fabrique des faux rouges.
# --- Démonstration : décider skip / passed / failed pour le raisonnement ---def verdict_raisonnement(modele_configure: bool, bloc_affiche: bool) ->str:ifnot modele_configure:return"skip (aucun modèle de raisonnement configuré)"if bloc_affiche:return"passed (bloc de raisonnement affiché)"return"skip (modèle configuré mais sans raisonnement visible)"for configure, bloc in [(False, False), (True, True), (True, False)]:print(f"configuré={configure}, bloc={bloc} -> {verdict_raisonnement(configure, bloc)}")
configuré=False, bloc=False -> skip (aucun modèle de raisonnement configuré)
configuré=True, bloc=True -> passed (bloc de raisonnement affiché)
configuré=True, bloc=False -> skip (modèle configuré mais sans raisonnement visible)
5. Concept — tester la compaction sans saturer le contexte
La compaction automatique résume la conversation quand on approche de la limite de contexte. Impossible de saturer le contexte dans un test rapide : on teste donc le comportement observable — plusieurs tours d’affilée continuent de répondre et le modèle garde le fil (il retrouve une info d’un tour précédent).
C’est un test de non-régression de la conversation longue : on ne mesure pas que la compaction se déclenche, on vérifie que la conversation reste cohérente quoi qu’il arrive côté résumé interne.
# --- Inventaire RÉEL via `npx playwright test --grep '06' --list` (sans exécuter) ---# Ne tourne que si npx + node_modules sont présents ; sinon repli propre.import shutil, subprocessdef lister_tests_module(serie: Path, grep: str="06"): npx = shutil.which("npx")ifnot npx ornot (serie /"node_modules").exists():returnNone# environnement Playwright non installé icitry: r = subprocess.run( [npx, "playwright", "test", "--grep", grep, "--list"], cwd=str(serie), capture_output=True, text=True, timeout=120, )return _redact(r.stdout or r.stderr)exceptExceptionas e: # pragma: no cover - dépend de l'environnementreturnf"(indisponible : {type(e).__name__})"res = lister_tests_module(SERIE)print(res if res else"Environnement Playwright non installé ici — voir « Exécuter ce module » dans le README.\n""Les titres ci-dessus (lecture du spec) suffisent pour raisonner sur le module.")
6. Interpréter le rapport — deux familles, un verdict
Le module 06 mêle deux familles qui ne se lisent pas pareil :
06a — API (cœur) : mémoire et dossiers. Ces tests doivent passer ; ils ne dépendent que du tenant principal.
06b — UI (optionnel) : raisonnement et compaction. Le raisonnement skippe proprement sans modèle « thinking » configuré ; le partage d’équipe skippe sans 2ᵉ compte. Un skipped ici n’est pas un échec.
Lire le rapport, c’est donc séparer le cœur (doit être vert) de l’optionnel (skip toléré) avant de conclure.
# --- Qualifier un rapport (échantillon module 06) ---rapport_exemple = [ {"test": "mémoire — endpoint disponible (liste)", "status": "passed"}, {"test": "mémoire — cycle add/list/delete champ type v0.10", "status": "passed"}, {"test": "dossiers — endpoint disponible (liste)", "status": "passed"}, {"test": "dossiers — cycle création / suppression", "status": "passed"}, {"test": "dossiers d'équipe — partage entre comptes", "status": "skipped"}, {"test": "raisonnement — le bloc de réflexion s'affiche", "status": "passed"}, {"test": "compaction — conversation multi-tours garde le fil", "status": "passed"},]def est_optionnel(nom: str) ->bool:returnany(k in nom for k in ("partage", "raisonnement", "compaction"))coeur = [r for r in rapport_exemple ifnot est_optionnel(r["test"])]optionnel = [r for r in rapport_exemple if est_optionnel(r["test"])]passed =sum(1for r in rapport_exemple if r["status"] =="passed")skipped =sum(1for r in rapport_exemple if r["status"] =="skipped")coeur_vert =all(r["status"] =="passed"for r in coeur)print(f"{passed} passed / {skipped} skipped")print("Cœur API (doit être vert) :", "VERT"if coeur_vert else"ROUGE")print("Optionnel UI (skip toléré) :", [(r["test"][:34], r["status"]) for r in optionnel])print("\nVerdict module 06 :", "VERT"if coeur_vert else"ROUGE")
6 passed / 1 skipped
Cœur API (doit être vert) : VERT
Optionnel UI (skip toléré) : [("dossiers d'équipe — partage entre ", 'skipped'), ('raisonnement — le bloc de réflexio', 'passed'), ('compaction — conversation multi-to', 'passed')]
Verdict module 06 : VERT
7. Exercices
Trois exercices, tous exécutables tels quels (ils tournent sans erreur et renvoient un placeholder). Remplace le corps de chaque fonction, ré-exécute, et compare au comportement attendu décrit en commentaire.
# Exercice 1 — Isolation par nettoyage (le pattern `finally` du §2)# Le bloc `finally` ne doit re-supprimer QUE si le nettoyage normal n'a pas eu lieu.def doit_nettoyer(deja_nettoye: bool) ->bool:# TODO : renvoyer True s'il faut (encore) supprimer l'objet créé.# Indice : c'est l'inverse de `deja_nettoye`.returnFalse# placeholder — à remplacerfor dn in (True, False):print(f"deja_nettoye={dn} -> doit_nettoyer={doit_nettoyer(dn)}")# Attendu après correction : False puis True.
# Exercice 2 — Détecter la version via un champ (feature detection, §3)def version_du_souvenir(souvenir: dict) ->str:# TODO : renvoyer "v0.10+" si le champ `type` est présent, sinon "antérieure".# Indice : souvenir.get("type").return"?"# placeholder — à remplacerfor ex in ({"type": "context"}, {}):print(f"{ex} -> {version_du_souvenir(ex)}")# Attendu après correction : "v0.10+" puis "antérieure".
{'type': 'context'} -> ?
{} -> ?
# Exercice 3 — Skip ou échec ? (distinguer pré-condition et régression, §4)def verdict(precondition_reunie: bool, assertion_ok: bool) ->str:# TODO : "skip" si la pré-condition n'est pas réunie ;# sinon "passed" / "failed" selon l'assertion.return"passed"# placeholder — à affinerfor p, a in [(False, False), (True, True), (True, False)]:print(f"précondition={p}, assertion={a} -> {verdict(p, a)}")# Attendu : skip / passed / failed.
Conclusion — la série couvre désormais l’ère agentique
Avec ce module bonus, la série Playwright-OWUI certifie aussi les nouveautés v0.10 : mémoire (avec isolation par nettoyage), dossiers, raisonnement streamé (skip propre) et compaction (comportement observable). Deux réflexes structurants à retenir :
Feature detection — prouver une version par la forme de la donnée (champ type) plutôt que par une constante codée en dur.
Skip ≠ échec — un test qui ne peut pas s’exercer se met en pause ; il ne fabrique pas de faux rouge.