Files
Password-Manager/TEST_SCENARIOS.md
T
2026-07-28 23:21:21 +01:00

43 KiB

Scénarios de test — écriture concurrente & fermeture de l'éditeur

Sessions du 2026-07-22. Tous les tests sont manuels (ils portent sur des interactions UI que le harness node:test ne couvre pas). Rebuild obligatoire avant de commencer : delphi-backend\assets\BuildAssets.cmd puis build Delphi.

Convention : PRÉ = état de départ, ACTION, ATTENDU. Un test qui échoue = régression, noter le commit courant.


Les 4 classes de bug

# Classe Cause racine Corrigé par
A Écriture extérieure écrasée au Save L'éditeur garde le payload chargé à l'ouverture et le re-poste entier ; toute écriture faite ailleurs entre-temps est perdue editorAbsorbChange (d146fbe)
B Éditeur fermé sans garde-fou closeSlideOver() appelé sans condition + click-outside non filtré closeEditorForDeleted (3dbce78), whitelist (e1ddc65)
C PUT /entries sans withEncryptedMeta Les colonnes en clair sont droppées côté serveur → le body ne contient rien de stockable et blanchit les *_enc d146fbe
D UI construite sans regarder la vue Le menu kebab est partagé entre vues ; la corbeille n'était pas testée d146fbe

A — Éditeur ouvert vs écriture extérieure

Préparer : 3 entrées A, B, C, dont A dans le dossier Work avec le tag old.

A1 · batch Add tag sur l'entrée éditée

  • PRÉ : A ouverte dans l'éditeur, taper du texte dans NOTE/PASSWORD (ne pas sauver)
  • ACTION : cocher A + B → barre de sélection → Add tag → shared → puis Save dans l'éditeur
  • ATTENDU : le chip shared apparaît immédiatement dans l'éditeur ; après Save, A porte old, shared et la modif tapée. Le bouton Save reste visible (l'entrée était déjà dirty) mais le tag seul ne rend pas dirty.✅

A2 · batch Add tag sur une autre entrée

  • PRÉ : A ouverte + dirty
  • ACTION : cocher B + C seulement → Add tag → x → Save dans l'éditeur
  • ATTENDU : l'éditeur ne bouge pas (aucun chip ajouté), A conserve ses tags, B/C taguées.✅

A3 · batch Move to folder sur l'entrée éditée

  • ACTION : A ouverte + dirty → cocher A → Move to folder… → Perso → Save
  • ATTENDU : le select Folder de l'éditeur bascule sur Perso tout seul ; après Save l'entrée est bien dans Perso.✅

A4 · dossier choisi dans l'éditeur puis move extérieur

  • ACTION : A ouverte, changer Folder → Archive (ne pas sauver) → cocher A → Move to folder… → Perso → Save
  • ATTENDU : Perso gagne (action extérieure = intention la plus récente). Comportement documenté, pas un bug. ✅

A5 · glisser une entrée sur un dossier (moveEntryToFolder)

  • ACTION : A ouverte + dirty → glisser la ligne A sur Perso dans la sidebar → Save
  • ATTENDU : identique à A3.✅

A6 · glisser une multi-sélection sur un dossier (moveEntriesToFolder) ✅

  • ACTION : cocher A + B → glisser sur Perso → recharger (F5 impossible : relancer l'app)
  • ATTENDU : dossier changé et nom / username / site / tags intacts.
  • Historique : ce chemin n'appelait pas withEncryptedMeta → il blanchissait les métadonnées chiffrées. Si des entrées ont été déplacées ainsi avant le fix, elles sont peut-être déjà vides — vérifier.

A7 · suppression du dossier de l'entrée éditée

  • ACTION : A (dossier Work) ouverte + dirty → sidebar → supprimer le dossier Work → Save
  • ATTENDU : le select de l'éditeur passe à All ; après Save l'entrée est dans All, le dossier supprimé ne réapparaît pas.✅

A8 · tags édités des deux côtés

  • ACTION : A ouverte, ajouter le chip mine dans l'éditeur (ne pas sauver) → batch Add tag shared sur A → Save
  • ATTENDU : old, mine, shared — aucun des trois perdu.✅

A9 · favori / épingle pendant l'édition (non-régression)

  • ACTION : A ouverte + dirty → cliquer ★ et 📌 sur la ligne A → Save
  • ATTENDU : l'entrée reste favorite et épinglée (ces colonnes ne sont pas dans le PUT de l'éditeur).✅

A10 · icône / pièce jointe pendant l'édition (non-régression)

  • ACTION : A ouverte + dirty → Settings → Fetch missing favicons (ou upload d'icône) → Save
  • ATTENDU : l'icône survit au Save (endpoint dédié, hors payload éditeur).✅

A10b · icône rafraîchie dans l'éditeur ouvert

  • ACTION : A ouverte → Settings → Fetch missing / Re-fetch all favicons
  • ATTENDU : l'aperçu d'icône dans l'éditeur se met à jour tout seul (pas seulement la carte). Idem après un import en mode overwrite qui apporte une icône.✅

A11 · sync / import pendant que l'éditeur est ouvert

  • ACTION : A ouverte + dirty → lancer Sync now (ou importer un JSON en mode overwrite) qui met A à jour → Save
  • ATTENDU : modale « Entry changed elsewhere ». Annuler ⇒ rien n'est écrit (la version du sync/import survit, l'éditeur reste ouvert le temps de recopier). Overwrite ⇒ la version de l'éditeur gagne.✅

A12 · pas de fausse alerte (non-régression d'A11)

Aucun de ces enchaînements ne doit afficher « Entry changed elsewhere » :

  • A12a : A ouverte + dirty → Save → modifier encore → Save (le rechargement post-save re-cale la référence)
  • A12b : A ouverte + dirty → batch Add tag / Move to folder sur A → Save (l'éditeur a absorbé le changement)
  • A12c : A ouverte + dirty → supprimer son dossier → Save (cf. A7)
  • A12d : A ouverte + dirty → Fetch missing favicons → Save (l'icône est hors payload éditeur)
  • A12e : A ouverte + dirty → sync sans changement distant sur A → Save

A13 · entrée supprimée ailleurs pendant l'édition

  • ACTION : A ouverte + dirty → un sync distant supprime A (tombstone) → Save
  • ATTENDU : pas de plantage. L'entrée n'est plus en mémoire → le contrôle A11 passe la main ; le PUT échoue proprement avec un toast d'erreur.

B — Fermeture de l'éditeur & message de discard

Le message n'apparaît que si confirmOnUnsaved est actif et que l'entrée est dirty. Tester avec askBeforeDelete ON puis OFF (Settings → Security).

B1 · ✕ sur une autre ligne ✅

  • PRÉ : A ouverte + dirty
  • ACTION : cliquer ✕ (Move to trash) sur la ligne B
  • ATTENDU : B part à la corbeille, l'éditeur de A reste ouvert avec les modifs. Vrai dans les deux réglages de askBeforeDelete.

B2 · ✕ sur la ligne éditée, askBeforeDelete = OFF ✅

  • ACTION : A ouverte + dirty → ✕ sur A
  • ATTENDU : modale « Discard unsaved changes? ». Annuler ⇒ rien n'est supprimé et l'éditeur reste ouvert. Confirmer ⇒ A à la corbeille + éditeur fermé.

B3 · ✕ sur la ligne éditée, askBeforeDelete = ON ✅

  • ATTENDU : une seule modale (« Move to trash »). Pas de double prompt.

B4 · batch Delete incluant l'entrée éditée ✅

  • ACTION : A ouverte + dirty → cocher A + B → Delete
  • ATTENDU : une seule modale (« 2 entries will be moved to trash »), puis l'éditeur se ferme.

B5 · batch Delete excluant l'entrée éditée ✅

  • ATTENDU : l'éditeur reste ouvert, modifs intactes.

B6 · corbeille — l'éditeur ne se ferme pas pour rien ✅

Prémisse corrigée : on ne peut pas ouvrir une entrée de la corbeille dans l'éditeur. openTrashActions (js/app.js) est un corps vide, et le menu kebab de la corbeille n'a pas d'entrée Edit — les actions sont sur la carte. Une entrée purgée n'est donc jamais celle qui est éditée, sauf par le chemin distant décrit plus bas.

  • ACTION : A ouverte + dirty (vue normale) → aller dans la corbeille → Delete forever sur une autre entrée, puis un batch Delete forever, puis Empty trash.
  • ATTENDU : l'éditeur de A reste ouvert avec ses modifs dans les trois cas — la purge ne concerne jamais l'entrée éditée.
  • Cas limite, seul moyen d'y arriver : A ouverte + dirty → un sync distant met A à la corbeille → Empty trash local → là closeEditorForDeleted doit fermer l'éditeur proprement (même famille qu'A13).

B7 · boutons de la barre de sélection ✅

  • ACTION : A ouverte + dirty → cocher B → cliquer successivement Move to folder…, Add tag, Clear
  • ATTENDU : aucun de ces clics ne ferme l'éditeur ni n'affiche de discard.

B8 · bouton Empty trash (barre de titre) ✅

  • ATTENDU : idem, pas de fermeture parasite de l'éditeur.

B9 · chemins de fermeture légitimes (non-régression) ✅

  • Esc, ✕ de l'éditeur, clic sur le fond de la grille, ouverture d'une autre entrée
  • ATTENDU : message de discard à chaque fois si dirty ; annuler garde l'éditeur ouvert sur la bonne entrée.

B10 · verrouillage (comportement assumé) ✅

  • ACTION : A ouverte + dirty → auto-lock ou Lock vault
  • ATTENDU : fermeture sans message, modifs perdues. Volontaire : la sécurité prime.

B11 · Esc au-dessus d'un modal ouvert par-dessus l'éditeur ✅

  • ACTION : A ouverte + dirty → ouvrir un modal → Esc → Esc
  • ATTENDU : 1ᵉʳ Esc ferme le modal seul, l'éditeur reste ouvert ; 2ᵉ Esc traite l'éditeur (discard).
  • Modals à passer un par un : + New folder (sidebar), Customize folder, Change master password, générateur de mot de passe, cheatsheet (?), historique de mot de passe, palette (Ctrl+K), outil TOTP, quick search (Ctrl+Shift+Q), reauth (déclenché par un export), picker d'autofill (Ctrl+Shift+L avec 2 entrées qui matchent).
  • Historique : #folderModal (dynamique) n'était dans aucune liste → Esc fermait l'éditeur derrière. #reauthModal, #quickSearchModal, #autofillPickerModal (statiques) non plus.

B12 · le modal reste fonctionnel après un Esc ✅

  • ACTION : ouvrir reauth (export) → Esc → ré-ouvrir (relancer l'export). Idem quick search et picker d'autofill.
  • ATTENDU : le modal se ré-affiche normalement. Un Esc ne doit jamais supprimer un modal statique du DOM (sinon la fonctionnalité est morte jusqu'au redémarrage).

B13 · clic sur un toast / l'overlay d'attente ✅

  • ACTION : A ouverte + dirty → lancer une action longue (export, sync) → cliquer sur le toast ou sur le spinner
  • ATTENDU : l'éditeur ne se ferme pas, pas de discard.

B14 · dialogues natifs ouverts par un clic programmatique

  • ACTION : A ouverte + dirty → (a) Settings → Import vault ; (b) télécharger une pièce jointe (fallback web) ; (c) Upload icon ; (d) joindre un fichier.
  • ATTENDU : seul le dialogue natif s'ouvre. Pas de « Discard unsaved changes? » par-dessus, l'éditeur reste ouvert et modifié.
  • Historique : le <input type=file> (et le <a download>) est attaché à <body> puis .click() → le clic synthétique remontait au handler click-outside qui le prenait pour un clic dehors. Gardé par e.isTrusted.

F — Round-trip export / import (drapeaux & provenance)

F1 · favori + épingle survivent au restore

  • F1a — entrée supprimée puis réimportée ✅ : épingler et mettre en favori une entrée → Export vault → supprimer l'entrée (corbeille + purge) → Import vault. ATTENDU : elle revient épinglée et favorite (chemin INSERT / bulk-import).
  • F1b — import en mode écrasement ✅ : même export, mais sans supprimer l'entrée. Retirer l'épingle et le favori localement, réimporter, choisir Overwrite.✅ ATTENDU : les deux drapeaux du fichier sont réappliqués.
  • Historique : pinned n'était dans aucun container ; favorite y était écrit mais jamais relu (ni parser, ni body, ni INSERT) → les deux perdus au restore. Puis F1a a marché sans F1b : le mode écrasement passe par PUT, qui ignore volontairement favorite/pinned pour qu'un sync ne puisse jamais écraser les drapeaux locaux (device-local par design). L'import les applique donc via les endpoints de bascule dédiés, uniquement quand ils diffèrent.

F2 · même chose depuis une auto-backup

  • ACTION : Backup now → supprimer l'entrée → importer le fichier vault-autobackup-*.json.
  • ATTENDU : identique à F1.

F3 · CSV étranger → drapeaux à 0

  • ACTION : importer un CSV sans colonnes favorite/pinned.
  • ATTENDU : entrées importées ni épinglées ni favorites (défaut 0), pas d'erreur.

F4 · le sync ne propage PAS les drapeaux (device-local, décidé 2026-07-11)

  • ACTION : épingler/favoriser une entrée sur le device A → Sync now → Sync now sur le device B.
  • ATTENDU : sur B, l'entrée n'est pas épinglée/favorite. Et un sync ne doit jamais retirer un drapeau posé localement sur B (le PUT serveur ne touche pas ces colonnes).

F5 · icône custom : provenance préservée ✅

  • ACTION : upload d'une icône custom → export → supprimer l'entrée → import → puis Re-fetch all.
  • ATTENDU : l'icône custom revient et survit au Re-fetch all (le flag icon_custom a fait le round-trip).

F6 · champ Notes multiligne — la forme survit ✅ (sync non testé)

  • ACTION : sur un login, Add note → saisir plusieurs lignes → Save → rouvrir l'entrée.
  • ATTENDU : toujours multiligne et masqué ; l'œil révèle. Le contenu conserve ses retours à la ligne.
  • ACTION 2 : dupliquer l'entrée, puis export → import. ACTION 3 (non testé) : Sync now vers un autre device.
  • ATTENDU : le champ reste multiligne partout — rien à ajouter dans ces chemins, il voyage dans custom_fields.
  • Pourquoi ce test existe : les champs personnalisés sont reconstruits clé par clé à deux endroits, la normalisation à l'ouverture et la reconstruction au Save, et chacun jette ce qu'il ne nomme pas. multiline est donc porté explicitement dans les deux ; sans ça le champ revient sur une ligne à la première réouverture (le contenu survit, mais illisible). Même piège que options pour les combobox.
  • Choix de conception : pas de colonne notes_enc dédiée. Le corps d'une entrée note est encrypted_password ; un vrai champ notes aurait voulu une nouvelle colonne chiffrée et les 7 points de passage de CLAUDE.md. custom_fields est déjà porté par la rotation, la duplication, l'export/import et la sync.

C — withEncryptedMeta sur tout write /entries

Test générique, à rejouer après toute modif touchant un body POST/PUT /entries : faire l'action, relancer l'app (pour relire depuis SQLite), vérifier que nom / site / username / tags s'affichent encore.

Chemins à couvrir : Save éditeur (neuf + existant), duplicate, import (bulk + overwrite), sync (applyRemoteSnapshot), addTagToEntry, batchAddTag, moveEntryToFolder, moveEntriesToFolder, batchMoveToFolder, migrateMetadataAtRest (au unlock), rotation du master password.

Symptôme du bug : après relance, lignes au nom vide / (no name).


D — Vue corbeille

D1 · menu kebab en table view

  • ACTION : corbeille + vue table → kebab d'une ligne
  • ATTENDU : Restore / Copy… / Delete forever uniquement. Pas de Pin / Favorite / Edit / Duplicate / Move to trash.

D2 · vues cartes et liste

  • ATTENDU : boutons restore + purge (déjà le cas avant), pas de drag possible.

D3 · clic sur une ligne de corbeille

  • ATTENDU : ouvre Trash actions, jamais l'éditeur (une entrée supprimée ne doit pas pouvoir être sauvegardée).

E — Auth / recovery / mémoire (série 2026-07-25)

Ces flux touchent la dérivation de clé et l'anti-lockout : tester sur un compte argon2id-v2 (défaut courant). Rebuild Delphi requis (fixes serveur) + BuildAssets pour le JS.

E1 · reauth — mauvais master pw reste inline (les 4 flux) ✅

  • ACTION : sur chacun de Set/Change PIN, Enable Quick Unlock, Generate recovery code, Export → taper un mauvais master pw.
  • ATTENDU : le modal reste ouvert avec « Wrong master password. Attempt N / 5. » ; au 5ᵉ échec ou 429 → toast lockout + fermeture. Annuler à tout moment = action abandonnée, rien fait.
  • Historique : PIN / Quick Unlock / recovery fermaient + toast au 1ᵉʳ essai.

E2 · recovery setup sur compte argon2id-v2 ✅

  • ACTION : Generate recovery code → bon master pw.
  • ATTENDU : code généré, pas de « invalid password ».
  • Historique : /recovery-key/setup rejetait tout compte -v2 / argon2id (vérif serveur ne connaissait que pbkdf2 / pbkdf2-sha256).

E3 · recovery generate → annuler n'écrit rien ✅

  • PRÉ : un code de recovery existe déjà.
  • ACTION : Regenerate code → bon master pw → à l'affichage du code, Cancel (ou Esc).
  • ATTENDU : toast « Cancelled — no recovery code was set » ; l'ancien code fonctionne toujours au redeem. Refaire avec I saved it → nouveau code actif, ancien invalidé.
  • Historique : le setup était POSTé avant l'affichage → annuler écrasait quand même l'ancien code (chemin silencieux vers le lockout).

E4 · recovery redeem sur compte argon2id-v2 ✅

  • ACTION : générer un code, se déconnecter, Use a recovery code → username + code.
  • ATTENDU : déverrouillage OK, redirection vers Change master password. Le coffre se déchiffre (clé dérivée avec les bons params Argon2 renvoyés par le serveur).
  • Historique : la réponse redeem n'echoait pas argon2 → params par défaut (correct aujourd'hui, lockout latent si params non-défaut).

E8 · le mode de déverrouillage survit au redémarrage ✅

  • ACTION : créer un PIN → Unlock method = PIN → verrouiller (le PIN est bien demandé) → fermer et relancer l'app.
  • ATTENDU : le PIN est demandé, pas le mot de passe maître.
  • Variantes : mode both → mot de passe puis PIN au démarrage à froid ; retour en mode pw → mot de passe seul ; 5 PIN faux (le blob DPAPI est effacé) → relancer → retour au mot de passe, sans blocage.
  • Historique : unlockMode n'existait que dans localStorage — vidé à chaque lancement, le port éphémère changeant l'origine — et dans settings_json, illisible avant d'être connecté. Au démarrage à froid l'écran d'auth retombait donc toujours sur 'pw', alors que le blob PIN était bien présent. Écrit désormais aussi en pref DPAPI (Bridge.setPref) et relu par showAuth, comme rememberedUsername. Le réglage est device-local par nature (le blob PIN est lié à ce Windows) : il reste dans settings_json pour l'historique, mais c'est la copie DPAPI qui fait autorité au boot.
  • Auto-correction : si la pref dit pin alors que le blob n'existe plus, applyAuthScreenMode force pw — pas de verrouillage possible.

E5 · la clé du coffre n'est plus en clair dans le web storage

  • ACTION : coffre déverrouillé → console WebView2 : sessionStorage.getItem('cryptoKey').
  • ATTENDU : null. (Avant : une string base64 de 44 car = la clé AES.)
  • Bonus : dump mémoire du renderer → this_is_password peut persister (strings V8, non corrigeable) ; c'est la clé qu'on a retirée du storage.

E6 · non-régression — les 4 exporteurs de clé marchent toujours

Après le retrait du cache sessionStorage, re-tester que ces flux (qui exportKey la clé) fonctionnent : login, Quick Unlock, PIN, recovery redeem, change master password.

E7 · non-régression — éditeur slideover après suppression du modal mort

Le générateur, les tags et la barre de force étaient partagés. Vérifier dans l'éditeur slideover : créer + éditer une entrée, ajouter/retirer des tags, Generate → Use insère le mot de passe, la barre de force réagit.


G — Vault health (série 2026-07-25)

G1 · bouton Fix quand le changement d'entrée est annulé

  • ACTION : A ouverte + dirty → aller sur Vault health → cliquer Fix sur une autre entrée B → Cancel au message discard.
  • ATTENDU : rien ne bouge. A reste ouverte, son mot de passe reste masqué, aucun clignotement du bouton Generate.
  • Puis refaire → Discard : B s'ouvre, c'est le mot de passe de B qui est démasqué + sélectionné, et c'est le Generate de B qui pulse.
  • Le focus RESTE sur le mot de passe (~1 s après le clic, vérifier que le curseur n'a pas sauté sur le titre) : openSlideOver programme un focus différé de 80 ms sur #soTitle, neutralisé ici par { noFocus: true }.
  • Historique : openSlideOver ne renvoyait rien, donc openEntryForFix continuait après une annulation et démasquait en clair le mot de passe de l'entrée restée ouverte.

G2 · Fix sur une entrée supprimée entre-temps

  • ACTION : ouvrir Vault health → dans un autre onglet de l'app, mettre à la corbeille une entrée listée → revenir sur health (sans Recompute) → Fix sur cette entrée.
  • ATTENDU : rien ne casse (l'éditeur ne s'ouvre pas, pas d'erreur JS). Avec G3 la liste devrait de toute façon être à jour.

G3 · le tableau de bord reflète les mutations

  • ACTION : ouvrir Vault health (noter le score) → revenir à la liste → faire une mutation : import, sync, suppression, batch delete, duplicate, restore, ou changement de master pw → rouvrir Vault health.
  • ATTENDU : score et listes recalculés (les entrées supprimées ont disparu, les importées apparaissent) sans cliquer Recompute.
  • Historique : le cache n'était vidé qu'au lock / au save / via Recompute — après un import ou une suppression le dashboard restait figé sur l'état précédent.

H — Hotkey 2FA (Ctrl+Shift+T, série 2026-07-25)

H1 · remplissage simple ✅

  • ACTION : entrée avec TOTP → ouvrir son site → cliquer le champ 2FA → Ctrl+Shift+T.
  • ATTENDU : le code à 6 chiffres est tapé dans le champ focalisé, toast « 2FA code filled: … ».

H2 · entrée sans TOTP

  • ACTION : fenêtre dont le titre matche une entrée sans secret TOTP → Ctrl+Shift+T.
  • ATTENDU : « No 2FA code stored for "…" » (et pas le « no match » générique, ni un remplissage du mot de passe).

H3 · picker multi-match

  • PRÉ : 2 entrées matchent le titre, une seule a un TOTP.
  • ATTENDU : le picker s'ouvre uniquement s'il reste ≥ 2 candidats avec TOTP ; ici il ne doit pas s'ouvrir (1 seul candidat éligible → remplissage direct). En-tête du picker : « Pick entry (2FA code) — ».

H4 · combo configurable

  • ACTION : Settings → Autofill → cliquer le bouton Ctrl+Shift+T → capturer un autre combo.
  • ATTENDU : accepté et actif immédiatement ; refusé s'il duplique un des 4 autres hotkeys ; Reset to defaults le remet à Ctrl+Shift+T ; le réglage survit à un redémarrage (synced via settings_json).

H5 · coffre verrouillé

  • ACTION : verrouiller → Ctrl+Shift+T.
  • ATTENDU : l'app revient au premier plan avec le focus sur le master password (même comportement que les autres hotkeys), pas de no-op silencieux.

H9 · retour au tray après un pick dans le picker ✅

  • ACTION : app au tray → Ctrl+Shift+L depuis une autre app sur un titre à plusieurs candidats → le picker sort la fenêtre du tray → choisir une entrée.
  • ATTENDU : le remplissage se fait puis l'app repart au tray, comme elle est venue.
  • Variantes : match unique (pas de picker) → reste au tray ; app déjà visible → reste visible ; app minimisée dans la barre des tâches → n'est pas envoyée au tray (not Self.Visible est faux pour une fenêtre iconique) ; picker fermé par Esc sans choisir → l'app repart au tray elle aussi (symétrique de quick-search, qui se cache sur Esc) et le hotkey suivant n'hérite pas de l'état capturé.
  • Historique : seul le chemin quick-search capturait « était cachée » (LWasHidden) et le faisait remonter via hide_after ; JS n'envoie jamais ce paramètre depuis le picker d'autofill, donc l'app restait sur le bureau. Capturé désormais côté Delphi dans BridgeAutofillRequest, au moment du hotkey — après, le picker a déjà détruit l'information.

H10 · le focus revient à l'application précédente ✅

Cacher une fenêtre ne transfère le premier plan à personne (contrairement à une minimisation). On restait donc au premier plan en étant invisible : le hotkey suivant capturait notre propre fenêtre comme cible et tapait le mot de passe dedans, et Ctrl+Shift+A ouvrait un « New entry » au titre vide (le garde refusait, à raison, de pré-remplir avec « Password Manager »).

  • ACTION : depuis une application X → ouvrir l'app (icône du tray, hotkey, ou lancement à froid) → fermer par la croix.
  • ATTENDU : X reprend le focus. Puis un hotkey d'autofill depuis X doit cibler X, et Ctrl+Shift+A pré-remplir son titre.
  • ACTION 2 : enchaîner plusieurs allers-retours en changeant d'application entre deux — c'est toujours la dernière réellement utilisée qui récupère le focus.
  • Non-régression : après un autofill (pick ou Esc), le focus reste sur la fenêtre remplie — ces deux chemins passent AReturnForeground = False, ayant déjà placé le premier plan eux-mêmes.
  • Historique — quatre tentatives, toutes tranchées par le log : (1) rendre le focus après avoir caché la fenêtre — Windows n'autorise que le propriétaire courant du premier plan à le céder, l'appel était silencieusement refusé ; (2) échantillonner le premier plan au moment du besoin — il vaut alors notre fenêtre, la capture se rejetait elle-même et n'apprenait jamais rien (prev=<none> à chaque fois) ; (3) capturer au clic sur l'icône du tray — on obtient une fenêtre du shell (NotifyIconOverflowWindow, « Fenêtre de dépassement de capacité de la barre d'état système ») ; (4) consommer la valeur après usage — le premier masquage marchait, les suivants plus. Solution : un hook EVENT_SYSTEM_FOREGROUND (WINEVENT_OUTOFCONTEXT, aucune injection) note chaque changement et garde le dernier qui n'est ni à nous ni au shell ; la restitution se fait avant de cacher.

H13 · pas de Ctrl+A après le Tab (formulaires type Reddit) ✅

  • ACTION : Ctrl+Shift+L sur un formulaire où Tab depuis le username ne mène pas au champ mot de passe (page de connexion Reddit : le Tab tombe à côté).
  • ATTENDU : plus de sélection de toute la page. Le mot de passe, lui, n'est toujours pas saisi sur ce formulaire — et ce n'est pas corrigeable : vérifié au Tab manuel, sans autofill, le Tab depuis le username va sur le bouton œil (révéler le mot de passe), que Reddit place avant le champ dans l'ordre de tabulation. On envoie des frappes sans jamais voir où le focus atterrit.
  • Contournement, seul chemin fiable sur ces formulaires : cliquer le champ mot de passe puis Ctrl+Shift+P (password seul, aucun Tab, aucune supposition) — testé sur Reddit, fonctionne.
  • Ne pas « corriger » en envoyant deux Tab : l'ordre de tabulation dépend de chaque site, un second Tab casserait tous les formulaires normaux.
  • Historique : la séquence envoyait Ctrl+A + Del avant chaque champ. Après le Tab c'était doublement mauvais : redondant (arriver par Tab sélectionne déjà tout le contenu du champ, cf. K1) et destructeur quand le Tab tombe sur un lien, un bouton ou le document — Ctrl+A sélectionne alors la page entière et la frappe se perd. Retiré ; celui d'avant le username reste (l'utilisateur y a placé le curseur par un clic, rien n'est sélectionné).

H11 · l'application précédente réduite reste réduite ✅

  • ACTION : depuis X, réduire X dans la barre des tâches → ouvrir le coffre (icône du tray) → le fermer par la croix.
  • ATTENDU : X reste réduite. Le focus part où Windows décide ; on ne rouvre pas une fenêtre que l'utilisateur a rangée.
  • Pourquoi c'est fragile : ForceForegroundWindow fait ShowWindow(SW_RESTORE) sur une cible iconique — il est écrit pour l'autofill, où sortir la fenêtre cible est justement le but. Réutilisé tel quel pour la restitution du focus, il changeait l'état de la fenêtre d'une autre application. Garde not IsIconic posé côté MinimizeToTray, pas dans ForceForegroundWindow (l'autofill en a toujours besoin).

H12 · démarrage -tray — l'application qui lance garde le focus ✅

  • ACTION : depuis une console PowerShell, lancer .\PMServer.exe -tray.
  • ATTENDU : le coffre démarre caché et la console garde son focus (rectangle actif dans la barre des tâches).
  • ACTION 2 : « Start with Windows » / redémarrage → rien ne passe au premier plan.
  • Historique : d'abord codé « rien à rendre, on n'a rien pris ». Faux : FMX affiche son proxy de barre des tâches avant qu'on le cache, donc le premier plan est pris un instant, et la console lançeuse restait inactive. La valeur utile est GStartupForeground, capturée à l'initialisation de l'unité — avant toute fenêtre à nous. Au démarrage de Windows, le premier plan est une fenêtre du shell, que la capture filtre : rien n'est activé, ce qui est le comportement voulu là.
  • Les quatre réglages de MinimizeToTray (AReturnForeground) : fermeture par la croix = rend ; quick-search copie-puis-cache = rend (c'est ce qui permet le Ctrl+V dans l'app d'origine) ; démarrage -tray = rend ; autofill et annulation du picker = ne rendent pas, seuls cas où le focus a déjà été placé volontairement (sur la fenêtre remplie).

H7 · combo déjà pris par une autre application ✅

  • ACTION : assigner à un hotkey un combo qu'une autre app tient déjà en global (ex. Ctrl+Shift+Q si Quick Assist / un lanceur l'occupe ; sinon, tester en assignant le même combo dans deux apps).
  • ATTENDU : toast nommant le combo refusé — « Already used by another app: 2FA code — pick another combo » — et un ⚠ ambre persistant sur cette ligne de Settings, et une pastille ambre sur l'entrée Settings de la sidebar (seul indice hors du panneau). Le toast d'avertissement reste ~10 s et se ferme au clic. Les autres hotkeys restent fonctionnels (enregistrement best-effort, pas de tout-ou-rien).
  • ACTION 2 : fermer l'app concurrente → Reset to defaults (ou re-capturer le combo) → le hotkey redevient actif et le ⚠ disparaît.
  • Piège de test : une app qui capture via un hook bas niveau (et non RegisterHotKey) avale la frappe sans faire échouer notre enregistrement → hotkey inerte sans ⚠. Utiliser AutoHotkey (^+t::MsgBox) ou Lightshot pour un vrai conflit.
  • Historique : le message disait « one or both hotkeys » (faux depuis qu'il y en a 3) sans dire lequel ; et un échec sur Quick search ou New entry n'était écrit que dans le panel debug — invisible en prod, le hotkey semblait juste cassé.

H8 · capturer un combo déjà pris par une autre app ✅

  • ACTION : Settings → cliquer le bouton du hotkey 2FA → presser un combo qu'une autre app détient (ex. Ctrl+Shift+T).
  • ATTENDU : au relâchement (pas après un délai), toast « No key registered after Ctrl+Shift. If you did press one, another app already owns that combo — pick a different one. » et le bouton reprend son libellé précédent.
  • ACTION 2 : presser un combo libre (ex. Ctrl+Win+T) → accepté, actif immédiatement, pas de ⚠.
  • ACTION 3 : cliquer le bouton puis ne rien presser pendant 10 s → « Hotkey capture cancelled — no key was pressed. » et le bouton se restaure (sinon la capture avalerait toutes les frappes jusqu'à Échap).
  • ACTION 4 : cliquer le bouton puis presser un combo déjà assigné à un autre de nos slots (ex. Ctrl+Shift+L) → « That combo is already used by another hotkey ». Doit fonctionner sans déclencher l'autofill : nos 5 hotkeys sont libérés pendant la capture.
  • Historique : un combo tenu par RegisterHotKey (le nôtre ou celui d'une autre app) n'atteint jamais WebView2 → la capture restait figée sur « Press combo… » sans message, et la vérif de collision interne était du code mort.

H6 · code en fin de période

  • ACTION : presser le hotkey juste avant la fin du compte à rebours de 30 s.
  • ATTENDU : le code tapé est celui de la période courante au moment de la frappe (généré à la demande, pas mis en cache).

I — État de la fenêtre (maximisé, série 2026-07-25)

L'app démarre maximisée (WindowState = wsMaximized dans la .fmx).

I1 · maximisé survit à Show desktop ✅

  • ACTION : fenêtre maximisée → Win+D (ou Show desktop) → revenir à l'app (barre des tâches, tray ou Alt+Tab).
  • ATTENDU : elle revient maximisée, sans passer par une frame en taille normale.
  • Historique : FMX oublie l'état maximisé quand Windows minimise la fenêtre de l'extérieur, et aucun de nos chemins de restore ne tourne pour un clic sur la barre des tâches.
  • Historique (flash) : une fois l'état final correct, il restait un scintillement — le cadre apparaissait en taille normale puis grandissait. C'est un bug FMX, reproduit sur une application FMX vide : FMX perd le maximisé quand Windows minimise le formulaire de l'extérieur. Windows choisit alors rcNormalPosition comme cible avant d'animer, donc aucune correction réactive ne peut arriver à temps — trois ont été essayées et rejetées par la mesure : ForceQueue (un tour de boucle trop tard), appel inline dans MINIMIZEEND (le hook est délivré après le WM_SIZE), correction dans le WM_SIZE lui-même (l'état devient parfait, le flash reste — preuve que le problème n'était plus l'état). Écarté aussi : poser WPF_RESTORETOMAXIMIZED, que GetWindowPlacement rapporte déjà à 2 sur une fenêtre maximisée (no-op). Statut : non corrigé, assumé. Une cinquième piste a été tentée puis annulée : garer rcNormalPosition et le remplacer par rcWork du moniteur pendant que la fenêtre est en bas, pour que « restaurer en normal » vaille déjà plein écran. Ça supprimait bien le flash, mais tout SetWindowPlacement sur une fenêtre en cours de minimisation — ou déjà iconique — laissait un moignon de fenêtre dans un coin de l'écran, y compris en différant l'écriture jusqu'à IsIconic. Un artefact visible permanent est pire que l'animation. Reverté. Ce qui reste : l'état final est correct (la fenêtre revient bien maximisée), seule l'animation de restauration est laide. Ne pas rouvrir sans une vraie raison — la seule route restante est de sous-classer le WndProc de la fenêtre FMX pour corriger le rectangle dans WM_WINDOWPOSCHANGING, ce qui insère du code dans la boucle de messages d'une fenêtre qu'on ne contrôle pas, avec le tray, l'autofill et le démarrage -tray en aval.

I2 · normal survit aussi ✅

  • ACTION : dé-maximiser la fenêtre → Win+D → revenir.
  • ATTENDU : elle reste en taille normale — le correctif ne doit jamais forcer le maximise.

I3 · maximiser / dé-maximiser reste enregistré ✅

  • ACTION : dé-maximiser → Win+D → retour (normal) → maximiser → Win+D → retour.
  • ATTENDU : chaque retour respecte le dernier geste de l'utilisateur.

I4 · retour depuis le tray ✅

  • ACTION : fenêtre maximisée → fermer (part au tray) → clic sur l'icône du tray.
  • ATTENDU : revient maximisée (chemin distinct : RestoreFromTray rejoue son propre placement).

I5 · le garde de minimisation ne reste pas bloqué (autofill → tray) ✅

Le chemin le plus subtil : ExecuteAutofill minimise réellement la fenêtre (SW_MINIMIZE) avant de taper, ce qui lève le garde ; si elle est ensuite cachée dans le tray au lieu d'être restaurée, MINIMIZEEND peut ne jamais arriver.

  • ACTION : depuis le tray, Ctrl+Shift+Q → choisir une entrée → laisser le remplissage se faire (l'app repart au tray) → rouvrir l'app par l'icône du tray → dé-maximiser (ou maximiser) → Win+D → revenir.
  • ATTENDU : le dernier geste est respecté. S'il ne l'est pas, le garde est resté levé et le suivi de l'état est gelé pour toute la session.
  • Variante : même chose avec Ctrl+Shift+L depuis une autre application.

I6 · le hook n'affecte pas la corbeille ni le tray (non-régression)

  • ACTION : minimiser dans le tray (fermeture), restaurer ; app/focus via le picker d'autofill multi-match ; « Lock vault » depuis le menu du tray.
  • ATTENDU : comportements inchangés. Le hook n'écoute que notre process et MinimizeToTray utilise SW_HIDE, qui n'émet aucun événement de minimisation.

Diagnostic

Avec config.txt contenant debug=true, Ctrl+Shift+D affiche le journal. Lignes utiles : [winstate] minimise hook installed=True au démarrage (un False signifierait que le hook Win32 ne s'installe pas → préservation inactive) et [winstate] re-maximising after restore quand la correction agit.


J — Quick search depuis le tray (série 2026-07-25)

Le flux le plus fragile : la fenêtre revient du tray, un modal s'ouvre, et la fermeture doit renvoyer l'app au tray. Deux cmd:// s'y enchaînent, ce qui est précisément le piège de coalescence documenté dans CLAUDE.md.

J1 · Esc dès l'ouverture, sans cliquer ✅

  • PRÉ : app dans le tray, coffre déverrouillé.
  • ACTION : Ctrl+Shift+Q → ne rien cliquer → Esc immédiatement.
  • ATTENDU : le modal se ferme du premier coup, et l'app repart au tray.
  • Historique : deux bugs distincts ici. (1) WebView2 n'avait pas le focus clavier de l'OS après la restauration → aucune frappe n'atteignait la page, Esc ne faisait rien tant qu'on n'avait pas cliqué dans le champ. (2) En mode fill, cancelAutofill puis minimizeToTray = deux window.location.href consécutifs → coalescés, la minimisation était perdue.

J2 · remplissage puis retour au tray ✅ ✅

  • ACTION : app dans le tray → Ctrl+Shift+Q → choisir une entrée (Enter).
  • ATTENDU : le mot de passe est tapé dans la fenêtre cible et l'app repart au tray — une seule fois (c'est Delphi qui minimise ici, via hide_after, pas le JS : vérifier qu'il n'y a pas de double minimisation).
  • Variantes : Shift+Enter (user+pwd), Ctrl+Enter (username seul).

J3 · copie depuis le menu du tray ✅

  • ACTION : menu du tray → Quick search… → choisir une entrée (Enter).
  • ATTENDU : mot de passe copié, app de retour au tray, et le presse-papiers conserve la valeur (keepclip=1 — c'est le rôle du paramètre).

J4 · app déjà visible (non-régression) ✅

  • ACTION : app ouverte à l'écran → Ctrl+Shift+Q → Esc.
  • ATTENDU : le modal se ferme et l'app reste à l'écran — elle ne doit pas partir au tray (elle n'en venait pas). Idem si elle était seulement minimisée dans la barre des tâches.

K — Autofill : comportements intentionnels (à ne pas « corriger »)

K1 · « Clear the field before typing » désactivé → le password est quand même remplacé

  • ACTION : Settings → Autofill → décocher Clear the field before typing. Puis remplissage complet (Ctrl+Shift+L, ou clic-droit dans la quick search) sur un formulaire dont les deux champs contiennent déjà quelque chose.
  • OBSERVÉ : le username n'est pas effacé (la frappe s'insère), mais l'ancien password disparaît et est remplacé.
  • C'EST NORMAL — vérifié de bout en bout : le JS envoie &clear=0, Delphi met FAutofillPendingClear := False. Notre code n'efface rien. (Depuis le correctif H13 il n'y a de toute façon plus qu'un seul ClearFieldIfWanted, avant le username : celui d'avant le password a été retiré.) La cause est le Tab du pipeline username → Tab → password : arriver dans un champ par Tab sélectionne tout son contenu (comportement standard Windows / navigateur), et la frappe remplace la sélection. Le username, lui, a été focalisé par un clic de l'utilisateur → curseur sans sélection → insertion.
  • Ce que le réglage fait réellement : empêcher l'envoi de Ctrl+A. Il existe pour les terminaux / bureaux distants où Ctrl+A signifie « début de ligne » ou déclenche le préfixe tmux, ce qui casserait le remplissage. Il ne promet pas de préserver le contenu des champs.
  • Ne pas « corriger » : désélectionner après le Tab ferait concaténer le nouveau mot de passe à l'ancien — jamais souhaitable.
  • Test qui isole la cause : Ctrl+Shift+P (password seul) en cliquant directement dans le champ → pas de Tab → l'ancienne valeur reste.

L — Démarrage avec Windows (-tray, série 2026-07-26)

Le flag -tray est ce que « Start with Windows » écrit dans HKCU\…\Run ("<exe>" -tray). Pour tester sans redémarrer : PMServer.exe -tray depuis le dossier de sortie. Ne pas laisser -tray dans Run → Parameters de l'IDE, sinon chaque F9 démarre caché.

L1 · démarrage caché, sans bouton de barre des tâches ✅

  • ACTION : lancer avec -tray (l'app n'étant pas déjà lancée — sinon le mutex single-instance fait sortir la nouvelle instance immédiatement).
  • ATTENDU : aucune fenêtre, aucun bouton dans la barre des tâches, icône présente dans le tray, pas de balloon.
  • Historique : le bouton restait 1-2 s. La fenêtre du formulaire n'était en fait jamais visible — c'est le proxy taskbar FMX (ApplicationHWND) qui s'affichait, et avant même que FormCreate soit atteint, ce qui rendait inopérant tout correctif posé depuis le formulaire. Le masquer ne suffit pas non plus : il faut retirer WS_EX_APPWINDOW et poser WS_EX_TOOLWINDOW, depuis le .dpr, juste après Application.Initialize.

L2 · le bouton revient à la restauration ✅

  • ACTION : après L1, clic sur l'icône du tray.
  • ATTENDU : la fenêtre s'ouvre et le bouton réapparaît dans la barre des tâches.
  • Historique : WS_EX_TOOLWINDOW est permanent — sans le rétablissement (TrayProxyShowInTaskbar dans RestoreWindowFromTray) l'app tournait ensuite sans aucune entrée de barre des tâches.

L3 · lancement normal (non-régression) ✅

  • ACTION : lancer sans -tray.
  • ATTENDU : fenêtre visible et bouton de barre des tâches présents, comme avant. Le correctif est entièrement gardé par le flag.

Reste à traiter (constaté pendant l'audit, non corrigé)

  1. Attachments hors garde A11. Le contrôle « changed elsewhere » couvre le payload de l'entrée, pas ses pièces jointes (table séparée, écrites par leurs propres endpoints). Un sync qui remplace une pièce jointe pendant l'édition n'est pas signalé. Faible risque : l'éditeur ne re-ship jamais les pièces jointes existantes, donc il n'y a rien à écraser.