G3 invalidates the cache on every mutation, so a stale health list can no longer be staged. Also records why the score moves in steps of 10 and the per-category caps that can make early fixes invisible.
55 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É :
Aouverte 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
sharedapparaît immédiatement dans l'éditeur ; après Save,Aporteold, sharedet 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É :
Aouverte + dirty - ACTION : cocher
B+Cseulement → Add tag →x→ Save dans l'éditeur - ATTENDU : l'éditeur ne bouge pas (aucun chip ajouté),
Aconserve ses tags,B/Ctaguées.✅
A3 · batch Move to folder sur l'entrée éditée
- ACTION :
Aouverte + dirty → cocherA→ Move to folder… →Perso→ Save - ATTENDU : le select Folder de l'éditeur bascule sur
Persotout seul ; après Save l'entrée est bien dansPerso.✅
A4 · dossier choisi dans l'éditeur puis move extérieur
- ACTION :
Aouverte, changer Folder →Archive(ne pas sauver) → cocherA→ Move to folder… →Perso→ Save - ATTENDU :
Persogagne (action extérieure = intention la plus récente). Comportement documenté, pas un bug. ✅
A5 · glisser une entrée sur un dossier (moveEntryToFolder)
- ACTION :
Aouverte + dirty → glisser la ligneAsurPersodans la sidebar → Save - ATTENDU : identique à A3.✅
A6 · glisser une multi-sélection sur un dossier (moveEntriesToFolder) ✅
- ACTION : cocher
A+B→ glisser surPerso→ 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(dossierWork) ouverte + dirty → sidebar → supprimer le dossierWork→ Save - ATTENDU : le select de l'éditeur passe à
All; après Save l'entrée est dansAll, le dossier supprimé ne réapparaît pas.✅
A8 · tags édités des deux côtés
- ACTION :
Aouverte, ajouter le chipminedans l'éditeur (ne pas sauver) → batch Add tagsharedsurA→ Save - ATTENDU :
old, mine, shared— aucun des trois perdu.✅
A9 · favori / épingle pendant l'édition (non-régression)
- ACTION :
Aouverte + dirty → cliquer ★ et 📌 sur la ligneA→ 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 :
Aouverte + 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 :
Aouverte → 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 :
Aouverte + dirty → lancer Sync now (ou importer un JSON en mode overwrite) qui metAà 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 :
Aouverte + dirty → Save → modifier encore → Save (le rechargement post-save re-cale la référence)✅ - A12b :
Aouverte + dirty → batch Add tag / Move to folder surA→ Save (l'éditeur a absorbé le changement)✅ - A12c :
Aouverte + dirty → supprimer son dossier → Save (cf. A7)✅ - A12d :
Aouverte + dirty → Fetch missing favicons → Save (l'icône est hors payload éditeur)✅ - A12e :
Aouverte + dirty → sync sans changement distant surA→ Save✅
A13 · entrée supprimée ailleurs pendant l'édition ✅
- ACTION :
Aouverte + dirty → un sync distant supprimeA(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 — surtout pas « Saved ».
- Recette locale, sans second appareil : ouvrir
Aet la rendre dirty → dans DB Browser, app lancée,DELETE FROM vault_entries WHERE id = <id>puis Write Changes (sans ça le WAL ne publie rien) → dans l'app, déclencher unloadEntries()qui ne concerne pasA(mettre une autre entrée à la corbeille puis Empty trash) → Save. - Bug trouvé par ce test : le serveur répondait 200 « Updated » pour un
UPDATEn'ayant touché aucune ligne — le client affichait « Saved » alors que rien n'était écrit. Perte de données silencieuse.HandleUpdateEntryteste maintenantRowsAffectedet renvoie 404. - Même classe, non corrigé : delete / restore / favorite / pin / set-icon répondent aussi OK sur un id inexistant. Assumé — aucun ne prétend avoir enregistré un travail de l'utilisateur, et le client recharge la liste juste après.
A13b · récupérer la saisie quand l'entrée a disparu ✅
- ACTION : même mise en place qu'A13 → au Save, répondre « Save as new entry ».
- ATTENDU : une nouvelle entrée est créée avec le contenu de l'éditeur, le panneau se rouvre dessus et la carte clignote. Cancel → rien n'est écrit, l'éditeur reste ouvert avec la saisie.
- Nouvel uuid, pas une résurrection : le POST en génère un neuf, donc l'entrée recréée n'entre pas en conflit avec le tombstone laissé par la suppression — aucun risque de la voir re-supprimée au sync suivant, ni de ressusciter en boucle ce qu'un autre appareil a effacé volontairement.
- Piège du déclenchement : le serveur HTTP répond aussi 404 à toute requête qu'il refuse (verrou par token). La bascule teste donc
status === 404et le message « no longer exists » — le statut seul confondrait une requête rejetée avec une entrée supprimée.
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É :
Aouverte + dirty - ACTION : cliquer ✕ (Move to trash) sur la ligne
B - ATTENDU :
Bpart à la corbeille, l'éditeur deAreste ouvert avec les modifs. Vrai dans les deux réglages deaskBeforeDelete.
B2 · ✕ sur la ligne éditée, askBeforeDelete = OFF ✅
- ACTION :
Aouverte + dirty → ✕ surA - 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 :
Aouverte + dirty → cocherA+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 :
Aouverte + 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
Areste 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 :
Aouverte + dirty → un sync distant metAà la corbeille → Empty trash local → làcloseEditorForDeleteddoit fermer l'éditeur proprement (même famille qu'A13).
B7 · boutons de la barre de sélection ✅
- ACTION :
Aouverte + dirty → cocherB→ 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 :
Aouverte + 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 :
Aouverte + 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+Lavec 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 :
Aouverte + 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.
B15 · Esc pendant une opération bloquante ✅
- ACTION :
Aouverte + dirty → lancer Sync now (ou un export, une rotation de mot de passe maître) → presserEscpendant que l'overlay « Encrypting… » est affiché. - ATTENDU : rien ne se passe. Pas de modale « Discard unsaved changes? ». Elle redevient possible une fois l'overlay parti.
- Historique : la modale s'ouvrait bel et bien, mais sous l'overlay (
z-index 200) — visible en flou, à demi cliquable. Deux problèmes en un : l'affichage, et surtout le fait d'arbitrer une fermeture d'éditeur pendant qu'une opération écrit sur la même entrée. GardeisBusy()posé dans la chaîne Esc de l'éditeur, avec les autres priorités (palette, Settings, modales). - Non-régression : le clic extérieur ne ferme déjà pas l'éditeur pendant l'attente —
#busyOverlayest dans la liste blanche, et il couvre l'écran de toute façon. - Tester les DEUX déclencheurs : Sync now et Export. Il y a deux gestionnaires
Escdistincts — celui en phase de capture dédié à l'éditeur, et la chaîne globale « une surface par frappe » (confirm → palette → change-master → cheatsheet → … → éditeur). Le premier correctif n'en couvrait qu'un, et l'export passait par l'autre : le bug semblait persister alors qu'il avait été corrigé sur le chemin du sync.
B16 · hotkeys globaux pendant une opération bloquante ✅
- ACTION : lancer un Export (ou Sync now) → pendant l'overlay, presser
Ctrl+Shift+A, puisCtrl+Shift+Q. - ATTENDU : ni l'éditeur ni la quick search ne s'ouvrent ; toast « Busy — try again in a moment ». Tout redevient normal une fois l'overlay parti.
- Pourquoi les hotkeys échappent aux gardes UI : ils arrivent de Win32, hors de la fenêtre. L'overlay qui couvre l'application n'intercepte rien : le panneau s'ouvrait derrière lui, inaccessible.
- Où est posé le garde : dans
openSlideOveretopenQuickSearchModal, pas sur chaque déclencheur — c'est le point de passage de tous les appelants (hotkey, palette de commandes, bouton Fix du vault health). Sûr parce qu'aucun chemin de sauvegarde n'affiche l'overlay : rien de légitime n'ouvre l'éditeur pendant une opération bloquante. - ACTION 2 — autofill : pendant l'overlay,
Ctrl+Shift+L,Ctrl+Shift+P,Ctrl+Shift+T, avec un titre à un seul candidat puis à plusieurs. - ATTENDU : rien ne se remplit, aucun picker, même toast. Le garde est dans
autofillHandleRequest, en amont du choix match-unique / picker, donc il couvre les trois hotkeys et les deux issues. Il appelleBridge.cancelAutofill(false)pour que Delphi ne garde pas la fenêtre cible capturée en attente d'un remplissage qui ne viendra jamais.
B17 · hotkeys pendant la boîte « Enregistrer sous » de l'export ✅
- ACTION : lancer un Export → à l'apparition du dialogue natif Enregistrer sous, basculer sur une autre application (VS Code) → presser les hotkeys d'autofill → revenir à l'app.
- ATTENDU : le dialogue est toujours là, au premier plan de l'application, et répond. L'app n'est cliquable qu'à travers lui — c'est normal, une boîte native modale désactive sa fenêtre propriétaire tant qu'on n'a pas répondu.
- Historique : le refus « busy » appelait
cancelAutofill(), dont le chemin Delphi fait unSetForegroundWindowvers la fenêtre capturée. Le dialogue passait derrière l'autre application, et l'app paraissait figée : bloquée par une modale devenue introuvable.refocus=0distingue désormais refuser une demande (ne rien déplacer) de terminer une interaction utilisateur (rendre le focus à la cible). - Généralisation : un refus ne doit jamais déplacer une fenêtre. Tout nouveau garde qui annule une action doit passer par ce chemin.
- Deuxième cause, côté Delphi :
Ctrl+Shift+QetCtrl+Shift+AappellentRestoreWindowFromTrayavant que JS ne puisse refuser — un garde JS arrive trop tard par construction. Restaurer la fenêtre propriétaire d'une modale native laisse le dialogue en rade derrière tout le reste : l'app semble gelée alors qu'elle est simplement désactivée par une boîte devenue introuvable (au point qu'il fallait un utilitaire de capture d'écran pour la faire réapparaître). - Garde :
NativeModalActivetesteIsWindowEnabledsur notre fenêtre — Windows la désactive tant qu'une modale native lui appartient. Posé en tête des quatre points d'entrée hotkey (quick search hotkey + menu tray, new entry, autofill). Il ne se contente pas de refuser : il ramène le dialogue au premier plan viaGetLastActivePopup, ce qui est précisément ce dont l'utilisateur a besoin. - ATTENDU après correctif : pendant l'« Enregistrer sous », n'importe quel hotkey fait réapparaître le dialogue au premier plan, avec le focus, et une notification tray explique que les raccourcis restent inactifs tant qu'il n'a pas répondu.
- Piège n°1, localiser le dialogue :
GetLastActivePopuprenvoie le formulaire lui-même — la boîte « Enregistrer sous » n'est pas une popup possédée par notre fenêtre. Elle vit en revanche sur notre thread avec la classe standard#32770, doncEnumThreadWindowsla trouve quel que soit son propriétaire. Mesuré : sans ça le bloc de remontée ne s'exécutait jamais, silencieusement. - Piège n°2, le focus :
SetForegroundWindowest refusé à un processus qui n'est pas déjà devant, et le hotkey vient d'une autre application. Il faut attacher notre file d'entrées au thread de la fenêtre de premier plan (VS Code). Réutiliser le helper d'autofill ne marche pas : il attache au thread de la cible, or le dialogue appartient à notre propre thread — l'attache est un no-op et le dialogue remontait sans jamais recevoir le focus. - Vérifié au log : le
WM_HOTKEYest bien distribué pendant la boucle modale du dialogue, etIsWindowEnableddétecte correctement l'état. Ces deux points étaient les hypothèses coûteuses à écarter avant de chercher ailleurs. - Piège du message : une balloon tray, pas un toast — notre fenêtre est désactivée et derrière le dialogue, rien de rendu dans la page ne serait visible. Elle suit le réglage « alertes tray » : désactivé, tu auras le focus sans le message.
B18 · clavier pendant une opération bloquante ✅
- ACTION : lancer un Sync now (ou un export) → pendant l'overlay, presser
Tabplusieurs fois puisEspace/Entrée. - ATTENDU : rien ne se passe. Aucun bouton derrière l'overlay ne prend le focus ni ne s'active. Tout redevient normal ensuite, focus compris.
- Historique : l'overlay ne bloque que la souris — il couvre la page. Le clavier passait au travers :
Tabentrait dans les contrôles derrière etEspaceles activait. Corrigé avec l'attribut natifinert(retire un sous-arbre du focus, du hit-testing et de l'arbre d'accessibilité), posé sur chaque enfant de<body>sauf l'overlay. - Point de vigilance :
hideBusy()retire l'inertie inconditionnellement. Une opération qui échouerait sans le rétablir laisserait une application qui semble gelée. inertne suffit pas seul : il retire les contrôles du focus, mais pas le défilement.Espace,Page haut/bas,Début/Finet les flèches font défiler le document sans aucun élément focalisé — la page bougeait encore sous l'overlay. Ces touches sont donc avalées en phase de capture tant que l'opération tourne.- Vérifier aussi le défilement : pendant l'overlay,
EspaceetPage basne doivent rien bouger ; après, le défilement clavier doit refonctionner.
B14 · dialogues natifs ouverts par un clic programmatique ✅
- ACTION :
Aouverte + 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é pare.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 :
pinnedn'était dans aucun container ;favoritey é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 parPUT, qui ignore volontairementfavorite/pinnedpour 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_customa fait le round-trip).
F7 · arbitrage last-write-wins du sync ✅
- PRÉ : sync configuré (un serveur WebDAV local suffit :
python -m wsgidav.server.server_cli --host=127.0.0.1 --port=8080 --root=<dir> --auth=anonymous, URL pointant un fichier, ex.http://127.0.0.1:8080/vault-sync.json). - ACTION : sync (le distant porte
aa) → changer le username enC, Save → fermer l'app → reculerupdated_atde l'entrée sous la date du distant, directement en base → fermer la base → relancer → sync sans ouvrir l'entrée. - ATTENDU : le username revient à
aa, toast1 updated. L'inverse (updated_atlocal plus récent) gardeC. - Piège n°1 — ne rien toucher entre les deux : ouvrir l'entrée et sauver quoi que ce soit émet un PUT, et tout PUT rebumpe
updated_at. Le recul manuel est alors annulé juste avant le sync et le local regagne : le test s'invalide lui-même. - Piège n°2 : c'est bien
updated_atqui arbitre (app.sync.js:423), pascreated_at. - Corollaire : quand le distant gagne, la branche PUT réécrit toute l'entrée. Une modification locale non poussée est donc perdue, pas fusionnée champ par champ — c'est le principe du last-write-wins par entrée.
- Éditeur ouvert pendant le sync : il ne se rafraîchit pas en direct, délibérément (ça écraserait la saisie en cours). Le garde-fou est au Save — modale « Entry changed elsewhere », cf. A11. Fermer sans sauver puis rouvrir montre la version issue du merge.
F6 · champ Notes multiligne — la forme survit ✅
- 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 : Sync now (aller-retour vérifié — pousser, supprimer localement, re-synchroniser pour faire revenir l'entrée du distant).
- Trois choses à vérifier séparément : le contenu avec ses retours à la ligne, la clé
multiline(rouvre en zone de texte), la cléis_secret(masqué, œil présent). Une perte se produirait à l'ouverture sur le device receveur, pas pendant le transfert — la sync portecustom_fieldscomme un blob opaque. - 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.
multilineest 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 queoptionspour les combobox. - Choix de conception : pas de colonne
notes_encdédiée. Le corps d'une entrée note estencrypted_password; un vrai champ notes aurait voulu une nouvelle colonne chiffrée et les 7 points de passage de CLAUDE.md.custom_fieldsest 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 foreveruniquement. 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/setuprejetait 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 modepw→ mot de passe seul ; 5 PIN faux (le blob DPAPI est effacé) → relancer → retour au mot de passe, sans blocage. - Historique :
unlockModen'existait que danslocalStorage— vidé à chaque lancement, le port éphémère changeant l'origine — et danssettings_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 parshowAuth, commerememberedUsername. Le réglage est device-local par nature (le blob PIN est lié à ce Windows) : il reste danssettings_jsonpour l'historique, mais c'est la copie DPAPI qui fait autorité au boot. - Auto-correction : si la pref dit
pinalors que le blob n'existe plus,applyAuthScreenModeforcepw— 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_passwordpeut 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 :
Aouverte + dirty → aller sur Vault health → cliquer Fix sur une autre entréeB→ Cancel au message discard. - ATTENDU : rien ne bouge.
Areste ouverte, son mot de passe reste masqué, aucun clignotement du bouton Generate. - Puis refaire → Discard :
Bs'ouvre, c'est le mot de passe de B qui est démasqué + sélectionné, et c'est le Generate deBqui 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) :
openSlideOverprogramme un focus différé de 80 ms sur#soTitle, neutralisé ici par{ noFocus: true }. - Historique :
openSlideOverne renvoyait rien, doncopenEntryForFixcontinuait 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 — sans objet
- ACTION prévue : ouvrir Vault health → mettre à la corbeille une entrée listée → revenir sur health sans Recompute → Fix sur cette entrée.
- Pourquoi c'est irréalisable : G3 a rendu ce cas impossible à mettre en scène — le cache est invalidé à chaque mutation, donc revenir sur le tableau de bord recalcule toujours et l'entrée supprimée n'y figure plus. Le garde reste en place dans le code (
openEntryForFixsort siopenSlideOverrenvoiefalse), il n'est simplement plus atteignable par l'interface.
G4 · le score bouge par paliers de 10 — comportement attendu
- OBSERVÉ : après correction de deux mots de passe,
30 → 40 → 50. - C'EST NORMAL : le barème est
-5par mot de passe faible,-10par groupe de réutilisation,-2par entrée ancienne,-15par entrée compromise. Corriger un mot de passe partagé par deux entrées casse le groupe →+10. - Effet de bord à connaître : chaque catégorie est plafonnée (
-40faible,-30réutilisé,-20ancien,-50compromis). Au-delà de 8 mots de passe faibles, en corriger un ne change rien au score tant qu'on n'est pas repassé sous le plafond. Le plafond existe pour qu'une seule catégorie ne puisse pas écraser les autres, mais il rend les premières corrections invisibles quand le coffre en compte beaucoup.
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 viasettings_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+Ldepuis 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.Visibleest 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 viahide_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 dansBridgeAutofillRequest, 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+Apré-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 hookEVENT_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+Lsur 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 + Delavant 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+Asé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 :
ForceForegroundWindowfaitShowWindow(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. Gardenot IsIconicposé côtéMinimizeToTray, pas dansForceForegroundWindow(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+Qsi 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
rcNormalPositioncomme 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 dansMINIMIZEEND(le hook est délivré après leWM_SIZE), correction dans leWM_SIZElui-même (l'état devient parfait, le flash reste — preuve que le problème n'était plus l'état). Écarté aussi : poserWPF_RESTORETOMAXIMIZED, queGetWindowPlacementrapporte 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 : garerrcNormalPositionet le remplacer parrcWorkdu 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 toutSetWindowPlacementsur 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 leWndProcde la fenêtre FMX pour corriger le rectangle dansWM_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-trayen 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 :
RestoreFromTrayrejoue 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+Ldepuis 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/focusvia 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
MinimizeToTrayutiliseSW_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 →Escimmé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,
cancelAutofillpuisminimizeToTray= deuxwindow.location.hrefconsé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 metFAutofillPendingClear := False. Notre code n'efface rien. (Depuis le correctif H13 il n'y a de toute façon plus qu'un seulClearFieldIfWanted, avant le username : celui d'avant le password a été retiré.) La cause est le Tab du pipelineusername → 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+Asignifie « 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 queFormCreatesoit atteint, ce qui rendait inopérant tout correctif posé depuis le formulaire. Le masquer ne suffit pas non plus : il faut retirerWS_EX_APPWINDOWet poserWS_EX_TOOLWINDOW, depuis le.dpr, juste aprèsApplication.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_TOOLWINDOWest permanent — sans le rétablissement (TrayProxyShowInTaskbardansRestoreWindowFromTray) 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é)
- 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.