É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 Pathimport re, shutil, subprocessdef _redact(texte: str) ->str:# Anti-fuite : ne jamais afficher de chemin absolu (machine de l'auteur, home...). out = textefor absolu in {str(Path.cwd().resolve()), str(Path.home())}: out = out.replace(absolu, ".") out = out.replace(absolu.replace("/", "\\"), ".")return outdef 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 candfor sub in Path.cwd().resolve().rglob("playwright.config.ts"):return sub.parentraiseFileNotFoundError("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 :
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.
É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.
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 TipTapawait page.locator('#chat-input').fill('Bonjour');// CORRECT — simule une vraie saisie clavierawait 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 simplestitres = 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 inenumerate(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)awaitexpect(page.locator(CHAT.assistantMessage).last()).toBeVisible({ timeout:30_000 });// Puis on attend la fin complete de la generationawaitwaitForResponse(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") isNone:returnNone, "npx introuvable — `npm install` requis (repli : inventaire statique ci-dessus)."ifnot (serie /"node_modules").exists():returnNone, "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())exceptExceptionas e:returnNone, f"{type(e).__name__}: {e}"code_retour, sortie = lister_tests_module(SERIE, "03")print("returncode:", code_retour)print(sortie if sortie else"(aucune sortie)")
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 WebUIOWUI_EMAIL=... # compte de testOWUI_PASSWORD=...OWUI_CLOUD_MODEL=gpt-5.1 # modèle cloud (rapide, fiable)OWUI_LOCAL_MODEL=# modèle local (vLLM/Ollama) — laisser vide pour skipperOWUI_PERSONA_MODEL=# modèle persona — laisser vide pour skipper
Puis :
npm installnpx playwright install chromiumnpm run test:module3 # ce module uniquementnpx playwright test --grep"03"--headed# navigateur VISIBLE (debug)PWDEBUG=1 npx playwright test --grep"03"--headed# Playwright Inspectornpm 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 exactexemples = ["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}")
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) +1return nbilan = qualifier(rapport_exemple)print("Bilan module 03 (echantillon) :", bilan)# 2 skips legitimes (services non configures) + 1 flaky (latence LLM) = aucun failedverdict ="GO"if bilan["failed"] ==0else"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.
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 reponsesans 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.returnFalse# 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"# placeholderfor 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 redef 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)
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.