Module 03 — Chat & Streaming LLM

Étape 3 de la mission « QA Engineer d’une flotte GenAI multi-tenant ». « Parle à l’IA, et prouve que le dialogue tient. »

Ce notebook est la couche pédagogique du module 03 — le cœur métier de la série. On teste l’interaction avec des modèles d’IA générative via l’interface de chat. Il raconte le module, lit le banc de tests réel (03-chat-streaming.spec.ts), en orchestre l’inventaire et propose des exercices exécutables. Le fichier .spec.ts reste le moteur de test (backend) : on ne le remplace pas, on l’enrobe.

Portabilité. Toutes les cellules de ce notebook s’exécutent sans navigateur ni instance Open WebUI en ligne : elles lisent le code des tests et raisonnent sur les concepts (non-déterminisme, streaming, skip gracieux). Lancer réellement les tests E2E (contre une vraie plateforme + un vrai LLM) est documenté au §4 et fait l’objet du capstone.

1. Objectifs & place dans la mission

La mission compte cinq étapes. Tu es à la troisième : l’accès est sécurisé (module 02), on attaque maintenant le cœur métier — le chat IA en streaming. C’est le module le plus riche, car tester une application d’IA générative pose des défis qu’aucun test E2E classique ne rencontre.

À la fin de ce module, tu sais :

  • gérer le non-déterminisme des LLM (assertions souples, jamais d’égalité stricte) ;
  • piloter l’éditeur TipTap (keyboard.type(), jamais fill()) ;
  • tester le streaming (le texte apparaît progressivement, le DOM change en continu) ;
  • écrire un skip gracieux quand un service LLM est indisponible ;
  • cibler des boutons localisés (FR/EN) via une regex.
Étape Module Ce que tu certifies
1 01 — Découverte Mon outillage fonctionne, je sais lire un test.
2 02 — Navigation & Auth L’accès et l’admin.
3 03 — Chat & Streaming (ici) Le cœur métier (chat IA).
4 04 — RAG, outils & canaux Les fonctions avancées.
5 05 — Multi-tenant & CI/CD L’isolation + l’industrialisation.
# --- Setup : localiser la serie et le module, SANS exposer de chemin absolu ---
from pathlib import Path
import re, shutil, subprocess

def _redact(texte: str) -> str:
    # Anti-fuite : ne jamais afficher de chemin absolu (machine de l'auteur, home...).
    out = texte
    for absolu in {str(Path.cwd().resolve()), str(Path.home())}:
        out = out.replace(absolu, ".")
        out = out.replace(absolu.replace("/", "\\"), ".")
    return out

def trouver_racine_serie(depart=None) -> Path:
    # La racine de la serie = le dossier qui contient playwright.config.ts.
    p = Path(depart or Path.cwd()).resolve()
    for cand in [p, *p.parents]:
        if (cand / "playwright.config.ts").exists():
            return cand
    for sub in Path.cwd().resolve().rglob("playwright.config.ts"):
        return sub.parent
    raise FileNotFoundError("playwright.config.ts introuvable depuis le dossier courant")

SERIE = trouver_racine_serie()
MODULE = SERIE / "03-chat-streaming"
SPEC = MODULE / "03-chat-streaming.spec.ts"

print("Serie       :", SERIE.name)
print("Module      :", MODULE.name)
print("Banc de test:", SPEC.name, "(present)" if SPEC.exists() else "(ABSENT)")
Serie       : Playwright-OWUI
Module      : 03-chat-streaming
Banc de test: 03-chat-streaming.spec.ts (present)

2. Le défi du test LLM — non-déterminisme et éditeur TipTap

Tester un chat IA n’est pas tester un formulaire classique. Trois défis changent tout :

  1. Non-déterminisme. Le même prompt ne donne jamais deux fois la même réponse. Une assertion stricte expect(response).toBe('Hello from GPT') cassera à chaque run. La règle d’or : assertions souples — expect(response.toLowerCase()).toContain('hello') vérifie la présence d’un fragment sans exiger l’exactitude.
  2. Éditeur TipTap. Open WebUI utilise TipTap (éditeur ProseMirror), pas un <textarea>. Conséquence : fill() ne déclenche pas les événements internes — il faut keyboard.type() pour simuler une vraie saisie.
  3. Latence variable. De 2 s (modèle cloud rapide) à 2 min (modèle local en réflexion). D’où des timeouts généreux et du polling plutôt qu’un waitForTimeout() arbitraire.
// FAUX — fill() ne declenche pas TipTap
await page.locator('#chat-input').fill('Bonjour');

// CORRECT — simule une vraie saisie clavier
await page.locator('#chat-input').click();
await page.keyboard.type('Bonjour', { delay: 10 });
await page.keyboard.press('Enter');

Retiens le réflexe anti-non-déterminisme : on asserte la forme de la réponse (un fragment, une longueur minimale, la présence d’un bloc), jamais son contenu exact.

# --- Lire le banc de tests et en extraire la structure (sans le lancer) ---
texte = SPEC.read_text(encoding="utf-8")

# Titres des tests : test('....', ...) — guillemets simples
titres = re.findall(r"test\('([^']+)'", texte)
# Le bloc describe (le theme du module)
describe = re.search(r"describe\('([^']+)'", texte)

print("Theme du module :", describe.group(1) if describe else "(non trouve)")
print()
print(f"{len(titres)} test(s) defini(s) dans {SPEC.name} :")
for i, t in enumerate(titres, 1):
    print(f"  {i}. {t}")

# Compter les exercices embarques (commentaires // EXERCICE)
nb_ex = len(re.findall(r"//\s*EXERCICE", texte))
print()
print(f"{nb_ex} exercice(s) embarque(s) dans les commentaires du spec.")
Theme du module : 03 — Chat & Streaming LLM

7 test(s) defini(s) dans 03-chat-streaming.spec.ts :
  1. chat avec modele cloud — reponse basique
  2. chat avec modele local (skip si indisponible)
  3. streaming — le message assistant apparait avant la fin
  4. rendu markdown — blocs de code formates
  5. regenerer une reponse
  6. editer un message utilisateur
  7. persona — reponse structuree dans le role configure

5 exercice(s) embarque(s) dans les commentaires du spec.

3. Streaming & polling — un DOM qui bouge en continu

En streaming, la réponse arrive token par token : le DOM est modifié en permanence. Deux gestes Playwright deviennent essentiels :

  • Distinguer « visible » et « complet ». Le message assistant devient visible avant la fin de la génération (preuve que le streaming a démarré). On asserte la visibilité tôt, puis on attend la fin.
  • Polling, pas waitForTimeout(). Attendre un délai fixe (ex. waitForTimeout(5000)) est fragile : trop court sur un modèle lent, trop long sur un modèle rapide. On préfère waitForFunction qui sonde le DOM jusqu’à ce qu’une condition soit vraie (ex. « le contenu a arrêté de changer »).
// Le message assistant apparait RAPIDEMENT (debut du streaming)
await expect(page.locator(CHAT.assistantMessage).last()).toBeVisible({ timeout: 30_000 });
// Puis on attend la fin complete de la generation
await waitForResponse(page);

Le helper waitForResponse() encapsule ce polling : il attend que le contenu soit stable plutôt qu’un délai arbitraire. C’est le pattern canonique du test de streaming — robuste à la latence variable.

# --- Inventaire REEL via Playwright : `npx playwright test --grep '03' --list` ---
# Liste les tests SANS les executer (--list) et SANS navigateur.
# Ne tourne que si npx + node_modules sont presents ; sinon repli propre.
def lister_tests_module(serie: Path, grep: str = "03"):
    if shutil.which("npx") is None:
        return None, "npx introuvable — `npm install` requis (repli : inventaire statique ci-dessus)."
    if not (serie / "node_modules").exists():
        return None, "node_modules absent — `npm install` requis (repli : inventaire statique ci-dessus)."
    try:
        r = subprocess.run(
            f"npx playwright test --grep {grep} --list",
            cwd=str(serie), shell=True, capture_output=True, text=True, timeout=180,
        )
        sortie = (r.stdout or "") + (r.stderr or "")
        return r.returncode, _redact(sortie.strip())
    except Exception as e:
        return None, f"{type(e).__name__}: {e}"

code_retour, sortie = lister_tests_module(SERIE, "03")
print("returncode:", code_retour)
print(sortie if sortie else "(aucune sortie)")
returncode: None
node_modules absent — `npm install` requis (repli : inventaire statique ci-dessus).

4. Lancer le module (pour de vrai)

L’inventaire ci-dessus se fait hors-ligne. Pour exécuter les tests contre une vraie instance Open WebUI + de vrais LLMs, il faut un .env configuré — jamais commité :

OWUI_URL=...                # instance Open WebUI
OWUI_EMAIL=...              # compte de test
OWUI_PASSWORD=...
OWUI_CLOUD_MODEL=gpt-5.1    # modèle cloud (rapide, fiable)
OWUI_LOCAL_MODEL=           # modèle local (vLLM/Ollama) — laisser vide pour skipper
OWUI_PERSONA_MODEL=         # modèle persona — laisser vide pour skipper

Puis :

npm install
npx playwright install chromium
npm run test:module3                         # ce module uniquement
npx playwright test --grep "03" --headed     # navigateur VISIBLE (debug)
PWDEBUG=1 npx playwright test --grep "03" --headed   # Playwright Inspector
npm run report                               # rapport HTML

L’exécution live dépend d’une plateforme, de secrets et de modèles LLM disponibles (coût, latence) : elle est différée ici (cœur du capstone). Ce notebook reste reproductible partout, sans secret ni appel LLM.

# --- Demonstration executable : classer une assertion (stricte vs souple) ---
# Pas de navigateur : on raisonne sur la *forme* de l'assertion (concept du para. 2).
# Un LLM etant non-deterministe, une assertion stricte casse a chaque run ;
# une assertion souple verifie la forme (fragment, longueur) sans exiger l'exactitude.
def type_assertion(assertion: str) -> str:
    a = assertion.lower()
    if "tocontain" in a:
        return "souple"    # tolere le non-determinisme (ex: toContain('hello'))
    if "tobe(" in a or "toequal" in a or "tohavetext(" in a:
        return "stricte"   # echoue a la moindre variation (ex: toBe('Hello from GPT'))
    return "neutre"        # presence / longueur, pas de contenu exact

exemples = [
    "expect(response).toBe('Hello from GPT')",                # stricte — cassera au moindre drift
    "expect(response.toLowerCase()).toContain('hello')",      # souple — robuste au non-determinisme
    "expect(response.length).toBeGreaterThan(0)",             # neutre — verifie juste la presence
    "expect(page.getByRole('button')).toBeVisible()",         # neutre — presence, pas contenu
]
for a in exemples:
    print(f"  {type_assertion(a):8} : {a}")
  stricte  : expect(response).toBe('Hello from GPT')
  souple   : expect(response.toLowerCase()).toContain('hello')
  neutre   : expect(response.length).toBeGreaterThan(0)
  neutre   : expect(page.getByRole('button')).toBeVisible()

5. Interpréter les résultats — skip gracieux, flaky, et non-déterminisme

Le module 03 introduit deux statuts qu’il faut savoir lire correctement :

  • skipped — volontairement ignoré à l’exécution via test.skip(condition, 'raison'). Sur le module 03, les tests « modèle local » et « persona » sont skippés tant que OWUI_LOCAL_MODEL / OWUI_PERSONA_MODEL ne sont pas configurés. Un skip n’est PAS une régression : c’est le contrat du test (il ne s’exécute que si la ressource existe). Le rapporteur indique la raison.
  • flaky — passe au retry après un premier échec. Sur le chat IA, c’est fréquent : latence variable, réponse plus lente que le timeout au premier essai. Un flaky mérite une investigation (timeout trop court ? polling trop serré ?) mais n’invalide pas la plateforme.
  • failed — sur un LLM, presque toujours un sélecteur cassé (drift d’interface) ou un timeout trop court, rarement une vraie régression métier. À investiguer côté harnais avant de conclure.

Piège spécifique au module 03 — le test.skip() runtime. Contrairement à un skip déclaré statiquement, test.skip(condition, 'raison') se décide pendant l’exécution : le test démarre, évalue la condition, et se marque skip si elle est fausse. C’est indispensable quand la condition dépend d’une variable d’env (ex. modèle local configuré ou non). Un test skippé pour « service indisponible » = comportement sain, pas un défaut.

# --- Qualifier un rapport (echantillon module 03) ---
rapport_exemple = [
    {"test": "chat avec modele cloud — reponse basique", "status": "passed"},
    {"test": "chat avec modele local (skip si indisponible)", "status": "skipped"},  # LOCAL_MODEL non configure
    {"test": "streaming — message assistant apparait avant la fin", "status": "passed"},
    {"test": "rendu markdown — blocs de code formates", "status": "passed"},
    {"test": "regenerer une reponse", "status": "flaky"},    # latence variable du LLM
    {"test": "editer un message utilisateur", "status": "passed"},
    {"test": "persona — reponse structuree", "status": "skipped"},  # PERSONA_MODEL non configure
]

def qualifier(rapport):
    n = {"passed": 0, "failed": 0, "skipped": 0, "flaky": 0}
    for t in rapport:
        n[t["status"]] = n.get(t["status"], 0) + 1
    return n

bilan = qualifier(rapport_exemple)
print("Bilan module 03 (echantillon) :", bilan)
# 2 skips legitimes (services non configures) + 1 flaky (latence LLM) = aucun failed
verdict = "GO" if bilan["failed"] == 0 else "NO-GO (investiguer les failed)"
print("Verdict provisoire :", verdict, "(skips legitimes exclus du verdict)")
Bilan module 03 (echantillon) : {'passed': 4, 'failed': 0, 'skipped': 2, 'flaky': 1}
Verdict provisoire : GO (skips legitimes exclus du verdict)

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 plus haut.

Exercice 1 — Assertion souple (anti-non-déterminisme)

Le spec laisse en commentaire : « Vérifiez que la réponse contient aussi “Playwright” ». Complète assertion_souple(reponse, mot_attendu) pour qu’elle renvoie True si mot_attendu apparaît dans reponse sans tenir compte de la casse — la forme canonique d’une assertion tolérante au non-déterminisme (cf §2).

def assertion_souple(reponse: str, mot_attendu: str) -> bool:
    # TODO : renvoyer True si mot_attendu est present dans reponse (insensible a la casse).
    # Indice : mettre les deux cotes en minuscules avant de tester l'inclusion.
    return False  # placeholder — a remplacer

# Test rapide (doit afficher True une fois complete) :
print("contient 'playwright' ?", assertion_souple("Hello from Playwright !", "playwright"))
contient 'playwright' ? False

Exercice 2 — Relier un test à son concept

Complète concept_du_test : à partir du titre d’un test du module 03, renvoie le concept principal qu’il illustre (ex. "cloud", "local", "streaming", "markdown", "regenerer", "editer", "persona"). Inspire-toi de l’inventaire du §2.

def concept_du_test(titre: str) -> str:
    # TODO : mapper un titre de test -> son concept principal
    # Ex : "streaming — le message assistant apparait..." -> "streaming"
    return "a determiner"  # placeholder

for t in ["chat avec modele cloud — reponse basique",
          "chat avec modele local (skip si indisponible)",
          "streaming — le message assistant apparait avant la fin",
          "rendu markdown — blocs de code formates",
          "regenerer une reponse",
          "editer un message utilisateur",
          "persona — reponse structuree dans le role configure"]:
    print(f"  {t!r} -> {concept_du_test(t)}")
  'chat avec modele cloud — reponse basique' -> a determiner
  'chat avec modele local (skip si indisponible)' -> a determiner
  'streaming — le message assistant apparait avant la fin' -> a determiner
  'rendu markdown — blocs de code formates' -> a determiner
  'regenerer une reponse' -> a determiner
  'editer un message utilisateur' -> a determiner
  'persona — reponse structuree dans le role configure' -> a determiner

Exercice 3 — La regex du bouton multilingue

Le bouton « Régénérer » s’écrit « Regénérer » en FR et « Regenerate » en EN. Le spec le cible via la regex /reg[ée]n[ée]r/i (insensible à la casse). Complète regex_bouton_multilingue() pour qu’elle renvoie cette regex sous forme de chaîne Python (le motif entre les / /, sans les délimiteurs). On valide la forme du pattern, pas une exécution navigateur.

import re

def regex_bouton_multiligue() -> str:
    # TODO : renvoyer le motif (sans les / /) qui matche "Regenerer" / "Regénérer" / "regenerate".
    # Indice : les accents aigus (e accent) peuvent ou non apparaitre a chaque position ciblee.
    return "a completer"  # placeholder

# Test rapide : le motif compile et matche les deux langues (insensible a la casse)
motif = regex_bouton_multiligue()
try:
    rx = re.compile(motif, re.IGNORECASE)
    print("matche 'Regénérer' :", bool(rx.search("Regénérer la réponse")))
    print("matche 'Regenerate' :", bool(rx.search("Regenerate response")))
except re.error as e:
    print("regex invalide :", e)
matche 'Regénérer' : False
matche 'Regenerate' : False

Conclusion & étape suivante

Tu sais désormais tester le cœur métier d’une appli GenAI : maîtriser le non-déterminisme (assertions souples), piloter TipTap (keyboard.type()), certifier le streaming (polling, waitForResponse), et skipper gracieusement un service indisponible sans faux échec. Tu as surtout intégré le réflexe fondateur : on asserte la forme d’une réponse LLM, jamais son contenu exact.

➡️ Étape 4 — Module 04 : RAG, outils & canaux. Tu quitteras le chat seul pour les fonctions avancées : bases de connaissances (RAG), appels d’outils, et canaux collaboratifs.


Note de reproductibilité (C.3). Les cellules de ce notebook ont été exécutées hors-ligne : lecture du .spec.ts, inventaire --list (sans navigateur) et démonstrations pure-Python. Aucune ne requiert d’instance Open WebUI, de secret ni d’appel à un LLM. L’exécution live des tests E2E (navigateur + plateforme + modèles) est volontairement différée au capstone et sera revalidée en Phase 3 (Open WebUI v0.9.6). Aucun claim de compatibilité v0.9.6 n’est porté par ce module.

Retour au sommet