Module 06 — Tester les nouveautés v0.10 (« l’ère agentique »)

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 Path
import re

def _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 = None
for base in (Path.cwd(), *Path.cwd().parents):
    if (base / "06-nouveautes.spec.ts").exists():
        MODULE = base
        break
    if (base / "06-nouveautes-v0.10" / "06-nouveautes.spec.ts").exists():
        MODULE = base / "06-nouveautes-v0.10"
        break
if MODULE is None:
    MODULE = Path.cwd()  # repli : exécuté hors du dossier du module

SPEC = MODULE / "06-nouveautes.spec.ts"
SERIE = MODULE.parent
print("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))
Module : 06-nouveautes-v0.10
Spec   : 06-nouveautes.spec.ts — présent
Série  : Playwright-OWUI

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.

const created = await addMemory(request, OWUI_URL, token, `${TAG} ...`);
let cleaned = false;
try {
  // ... assertions ...
  cleaned = await deleteMemory(request, OWUI_URL, token, created.id);
} finally {
  if (!cleaned) await deleteMemory(request, OWUI_URL, token, created.id).catch(() => {});
}

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.")
2 bloc(s) describe, 7 test(s) déclaré(s) :

  ▸ 06a — API : memoire & dossiers (v0.10)
  ▸ 06b — UI : raisonnement & compaction (v0.10)

   - memoire — endpoint disponible (liste)
   - memoire — cycle add / list / delete avec champ type v0.10
   - dossiers — endpoint disponible (liste)
   - dossiers — cycle creation / suppression
   - dossiers d'equipe — partage entre comptes (exercice)
   - raisonnement — le bloc de reflexion s'affiche
   - compaction — conversation multi-tours garde le fil

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:
    if not 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, subprocess

def lister_tests_module(serie: Path, grep: str = "06"):
    npx = shutil.which("npx")
    if not npx or not (serie / "node_modules").exists():
        return None  # environnement Playwright non installé ici
    try:
        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)
    except Exception as e:  # pragma: no cover - dépend de l'environnement
        return f"(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.")
Listing tests:
  [owui-setup] › fixtures\auth.setup.ts:18:1 › authenticate
  [owui] › 06-nouveautes-v0.10\06-nouveautes.spec.ts:75:3 › 06a — API : memoire & dossiers (v0.10) › memoire — endpoint disponible (liste)
  [owui] › 06-nouveautes-v0.10\06-nouveautes.spec.ts:90:3 › 06a — API : memoire & dossiers (v0.10) › memoire — cycle add / list / delete avec champ type v0.10
  [owui] › 06-nouveautes-v0.10\06-nouveautes.spec.ts:120:3 › 06a — API : memoire & dossiers (v0.10) › dossiers — endpoint disponible (liste)
  [owui] › 06-nouveautes-v0.10\06-nouveautes.spec.ts:132:3 › 06a — API : memoire & dossiers (v0.10) › dossiers — cycle creation / suppression
  [owui] › 06-nouveautes-v0.10\06-nouveautes.spec.ts:167:3 › 06a — API : memoire & dossiers (v0.10) › dossiers d'equipe — partage entre comptes (exercice)
  [owui] › 06-nouveautes-v0.10\06-nouveautes.spec.ts:195:3 › 06b — UI : raisonnement & compaction (v0.10) › raisonnement — le bloc de reflexion s'affiche
  [owui] › 06-nouveautes-v0.10\06-nouveautes.spec.ts:240:3 › 06b — UI : raisonnement & compaction (v0.10) › compaction — conversation multi-tours garde le fil
Total: 8 tests in 2 files

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:
    return any(k in nom for k in ("partage", "raisonnement", "compaction"))

coeur = [r for r in rapport_exemple if not est_optionnel(r["test"])]
optionnel = [r for r in rapport_exemple if est_optionnel(r["test"])]
passed = sum(1 for r in rapport_exemple if r["status"] == "passed")
skipped = sum(1 for 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`.
    return False  # placeholder — à remplacer

for dn in (True, False):
    print(f"deja_nettoye={dn} -> doit_nettoyer={doit_nettoyer(dn)}")
# Attendu après correction : False puis True.
deja_nettoye=True -> doit_nettoyer=False
deja_nettoye=False -> doit_nettoyer=False
# 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 — à remplacer

for 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 — à affiner

for p, a in [(False, False), (True, True), (True, False)]:
    print(f"précondition={p}, assertion={a} -> {verdict(p, a)}")
# Attendu : skip / passed / failed.
précondition=False, assertion=False -> passed
précondition=True, assertion=True -> passed
précondition=True, assertion=False -> passed

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 :

  1. Feature detection — prouver une version par la forme de la donnée (champ type) plutôt que par une constante codée en dur.
  2. Skip ≠ échec — un test qui ne peut pas s’exercer se met en pause ; il ne fabrique pas de faux rouge.

Pour aller plus loin : ../WHATS-NEW-v0.10.md (côté étudiant) et le Tour de la plateforme (côté utilisateur).


Module 06 — Série Playwright-OWUI (CoursIA GenAI). Cible : Open WebUI v0.10.2.

Retour au sommet