chore(cleanup): untrack debris pre-BFG — audio, PEM, screenshots, reports
Phase 0 (J2 cleanup) of chore/v1.0.7-cleanup branch. Pure index removals
before BFG history rewrite. No working-tree changes, no code touched.
Removed from git index (still on disk):
- 44× veza-backend-api/uploads/*.mp3 (audio fixtures, ~200MB)
- 23× root PNG screenshots (design-system, forgot-password,
register, reset-password, settings,
storybook — various prefixes)
- 1× docker/haproxy/certs/veza.pem (self-signed dev cert, regen via
scripts/generate-ssl-cert.sh)
- 1× generate_page_fix_prompts.sh (one-off generated tooling)
- 4× apps/web/*.json (AUDIT_ISSUES, audit_remediation,
lint_comprehensive, storybook-roadmap)
.gitignore enriched (post-audit J2 block) to prevent recommits:
- veza-backend-api/uploads/ (audio fixtures → git-lfs or external)
- config/ssl/*.{pem,key,crt}
- .playwright-mcp/ (MCP session debris)
- CLAUDE_CONTEXT.txt, UI_CONTEXT_SUMMARY.md, *.context.txt (AI session artefacts)
- Root PNG prefixes beyond existing rules
- apps/web/{AUDIT_ISSUES,audit_remediation,lint_comprehensive,storybook-*}.json
- /generate_page_fix_prompts.sh, /build-archive.log
Next: BFG for history rewrite to compact .git (currently 2.3 GB).
Refs: AUDIT_REPORT.md §9.1, FUNCTIONAL_AUDIT.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6d51f52aae
commit
d12b901de5
2 changed files with 40 additions and 980 deletions
40
.gitignore
vendored
40
.gitignore
vendored
|
|
@ -196,3 +196,43 @@ veza-backend-api/backend*.log
|
|||
|
||||
# AI tooling session state (not code)
|
||||
.cursor/
|
||||
|
||||
# ============================================================
|
||||
# Post-audit J2 (2026-04-20) — branch chore/v1.0.7-cleanup
|
||||
# ============================================================
|
||||
|
||||
# Tracked audio fixtures — use git-lfs or fixtures repo, never commit raw audio
|
||||
veza-backend-api/uploads/
|
||||
|
||||
# TLS/SSL certificates committed pre-2026-04 (regen with scripts/generate-ssl-cert.sh)
|
||||
config/ssl/*.pem
|
||||
config/ssl/*.key
|
||||
config/ssl/*.crt
|
||||
|
||||
# Playwright MCP session debris
|
||||
.playwright-mcp/
|
||||
|
||||
# AI session artefacts / context dumps
|
||||
CLAUDE_CONTEXT.txt
|
||||
UI_CONTEXT_SUMMARY.md
|
||||
*.context.txt
|
||||
*.ai-session.txt
|
||||
|
||||
# One-off generated tooling scripts (should live in scripts/ if kept)
|
||||
/generate_page_fix_prompts.sh
|
||||
/build-archive.log
|
||||
|
||||
# Apps/web stale audit reports (generated, never tracked)
|
||||
apps/web/AUDIT_ISSUES.json
|
||||
apps/web/audit_remediation.json
|
||||
apps/web/lint_comprehensive.json
|
||||
apps/web/storybook-roadmap.json
|
||||
apps/web/storybook-*.json
|
||||
|
||||
# Root PNG screenshots — move to docs/screenshots/ if historical value
|
||||
/design-system-*.png
|
||||
/forgot-password-*.png
|
||||
/register-*.png
|
||||
/reset-password-*.png
|
||||
/settings-*.png
|
||||
/storybook-*.png
|
||||
|
|
|
|||
|
|
@ -1,980 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
VEZA — Générateur de prompts Claude Code pour audit exhaustif de toutes les pages.
|
||||
Génère un prompt par route, prêt à être copié-collé dans Claude Code avec Playwright MCP.
|
||||
|
||||
Usage:
|
||||
python veza-prompt-generator.py # Génère tous les prompts dans ./prompts/
|
||||
python veza-prompt-generator.py --route /settings # Génère un seul prompt
|
||||
python veza-prompt-generator.py --list # Liste toutes les routes
|
||||
python veza-prompt-generator.py --batch 1 # Génère le batch 1 (routes groupées)
|
||||
python veza-prompt-generator.py --combined # Un seul fichier avec tous les prompts
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Configuration
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
OUTPUT_DIR = "./prompts"
|
||||
DOMAIN = "veza.fr"
|
||||
|
||||
TEST_ACCOUNTS = [
|
||||
{"role": "admin", "email": "admin@veza.music", "password": "Admin123!"},
|
||||
{"role": "creator", "email": "artist@veza.music", "password": "Artist123!"},
|
||||
{"role": "user", "email": "user@veza.music", "password": "User123!"},
|
||||
{"role": "moderator", "email": "mod@veza.music", "password": "Mod123!"},
|
||||
{"role": "user_new", "email": "new@veza.music", "password": "New123!"},
|
||||
]
|
||||
|
||||
E2E_TEST_DIR = "veza-e2e/"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Route definitions
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Route:
|
||||
number: int
|
||||
path: str
|
||||
name: str
|
||||
category: str
|
||||
auth_required: bool = True
|
||||
role: str = "user"
|
||||
params: str = ""
|
||||
test_with_accounts: list = field(default_factory=list)
|
||||
specific_checks: list = field(default_factory=list)
|
||||
|
||||
|
||||
ROUTES = [
|
||||
# ── Pages publiques ──
|
||||
Route(1, "/login", "Connexion", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Formulaire email + password fonctionnel",
|
||||
"Validation des champs (email invalide, champs vides)",
|
||||
"Messages d'erreur (mauvais credentials)",
|
||||
"Lien 'Mot de passe oublié' fonctionnel",
|
||||
"Lien 'Inscription' fonctionnel",
|
||||
"Redirection après login réussi",
|
||||
"Protection contre brute-force (rate limiting visible)",
|
||||
"Autocomplete attributes sur les inputs",
|
||||
"Accessibilité: labels, focus order, aria",
|
||||
]),
|
||||
Route(2, "/register", "Inscription", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Tous les champs du formulaire (username, email, password, confirm)",
|
||||
"Validation temps réel (email format, password strength, username dispo)",
|
||||
"Messages d'erreur clairs pour chaque règle de validation",
|
||||
"Soumission et retour serveur (201, 409 duplicate, 422 validation)",
|
||||
"Lien vers /login fonctionnel",
|
||||
"CGU / checkbox conditions d'utilisation si présent",
|
||||
"Accessibilité: labels, focus, aria, autocomplete",
|
||||
]),
|
||||
Route(3, "/forgot-password", "Récupération mot de passe", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Formulaire email fonctionnel",
|
||||
"Message de confirmation après soumission",
|
||||
"Gestion email inexistant (pas de leak d'info)",
|
||||
"Lien retour vers /login",
|
||||
"Rate limiting sur les soumissions",
|
||||
]),
|
||||
Route(4, "/verify-email", "Vérification email", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Comportement avec token valide vs invalide vs expiré",
|
||||
"Message de succès / erreur approprié",
|
||||
"Redirection après vérification",
|
||||
"Gestion du cas sans token dans l'URL",
|
||||
]),
|
||||
Route(5, "/reset-password", "Réinitialisation mot de passe", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Formulaire nouveau password + confirmation",
|
||||
"Validation password strength côté client",
|
||||
"Comportement avec token valide vs invalide vs expiré",
|
||||
"Message de succès + redirection vers /login",
|
||||
"Autocomplete attributes",
|
||||
]),
|
||||
Route(6, "/launch", "Landing page pré-lancement", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Rendu visuel complet (hero, sections, CTA)",
|
||||
"Tous les liens/CTA fonctionnels",
|
||||
"Responsive (mobile/tablet/desktop)",
|
||||
"Performance de chargement",
|
||||
"SEO: title, meta description, OG tags",
|
||||
]),
|
||||
Route(7, "/design-system", "Démo du design system", "public", auth_required=False,
|
||||
specific_checks=[
|
||||
"Tous les composants se rendent sans erreur",
|
||||
"Pas d'erreurs console",
|
||||
"Interactivité des composants démo (boutons, toggles, modals)",
|
||||
]),
|
||||
Route(8, "/u/:username", "Profil public utilisateur", "public", auth_required=False,
|
||||
params="username",
|
||||
specific_checks=[
|
||||
"Affichage avec username existant (ex: tester avec chaque compte)",
|
||||
"Page 404 avec username inexistant",
|
||||
"Infos publiques affichées (avatar, bio, tracks publiques)",
|
||||
"Pas de fuite d'infos privées (email, settings)",
|
||||
"Boutons follow/unfollow si connecté",
|
||||
"Liste des tracks/playlists publiques",
|
||||
"Liens fonctionnels vers les tracks",
|
||||
]),
|
||||
Route(9, "/playlists/shared/:token", "Playlist partagée", "public", auth_required=False,
|
||||
params="token",
|
||||
specific_checks=[
|
||||
"Affichage avec token valide",
|
||||
"Gestion token invalide / expiré",
|
||||
"Lecture des tracks depuis le partage",
|
||||
"Infos playlist (titre, description, nombre de tracks)",
|
||||
]),
|
||||
|
||||
# ── Navigation principale ──
|
||||
Route(10, "/dashboard", "Tableau de bord", "main_nav",
|
||||
test_with_accounts=["user", "creator", "admin"],
|
||||
specific_checks=[
|
||||
"Widgets / cartes de stats se chargent",
|
||||
"Données cohérentes avec le rôle (creator voit ses stats artiste)",
|
||||
"Liens rapides fonctionnels",
|
||||
"Activité récente / feed",
|
||||
"Pas d'erreurs réseau dans la console",
|
||||
"Responsive layout",
|
||||
]),
|
||||
Route(11, "/discover", "Découverte musicale", "main_nav",
|
||||
specific_checks=[
|
||||
"Sections de découverte (trending, new releases, genres)",
|
||||
"Cartes de tracks/albums cliquables",
|
||||
"Lecture depuis la page discover",
|
||||
"Filtres / catégories fonctionnels",
|
||||
"Infinite scroll ou pagination",
|
||||
"État vide si aucun contenu",
|
||||
]),
|
||||
Route(12, "/feed", "Fil d'actualité", "main_nav",
|
||||
specific_checks=[
|
||||
"Posts / activités des artistes suivis",
|
||||
"Infinite scroll ou pagination",
|
||||
"Interactions (like, comment, share)",
|
||||
"État vide (aucun artiste suivi)",
|
||||
"Chargement sans erreurs réseau",
|
||||
]),
|
||||
Route(13, "/library", "Bibliothèque musicale", "main_nav",
|
||||
test_with_accounts=["user", "creator"],
|
||||
specific_checks=[
|
||||
"Liste des tracks / albums / playlists de l'utilisateur",
|
||||
"Tri et filtres fonctionnels",
|
||||
"Recherche dans la bibliothèque",
|
||||
"Actions sur les items (play, add to playlist, delete)",
|
||||
"État vide pour un nouveau compte",
|
||||
"Pagination / infinite scroll",
|
||||
]),
|
||||
Route(14, "/queue", "File de lecture", "main_nav",
|
||||
specific_checks=[
|
||||
"Affichage de la queue actuelle",
|
||||
"Drag & drop pour réordonner",
|
||||
"Suppression d'items de la queue",
|
||||
"Bouton clear queue",
|
||||
"État vide",
|
||||
"Persistance après navigation",
|
||||
]),
|
||||
Route(15, "/search", "Recherche globale", "main_nav",
|
||||
specific_checks=[
|
||||
"Barre de recherche fonctionnelle",
|
||||
"Résultats par catégorie (tracks, artists, playlists, albums)",
|
||||
"Recherche en temps réel / debounce",
|
||||
"Résultats cliquables et navigation correcte",
|
||||
"État 'aucun résultat'",
|
||||
"Historique de recherche si présent",
|
||||
]),
|
||||
|
||||
# ── Playlists & Tracks ──
|
||||
Route(16, "/playlists", "Liste des playlists", "playlists",
|
||||
specific_checks=[
|
||||
"Liste des playlists de l'utilisateur",
|
||||
"Bouton créer une playlist",
|
||||
"Cartes playlist cliquables",
|
||||
"Infos affichées (titre, nombre de tracks, durée, cover)",
|
||||
"Actions (edit, delete, share)",
|
||||
"État vide",
|
||||
]),
|
||||
Route(17, "/playlists/favoris", "Playlist favoris", "playlists",
|
||||
specific_checks=[
|
||||
"Liste des tracks favorites",
|
||||
"Bouton unfavorite fonctionnel",
|
||||
"Lecture depuis les favoris",
|
||||
"Tri (date ajoutée, titre, artiste)",
|
||||
"État vide",
|
||||
]),
|
||||
Route(18, "/playlists/:id", "Détail playlist", "playlists",
|
||||
params="id",
|
||||
specific_checks=[
|
||||
"Header playlist (titre, description, cover, auteur)",
|
||||
"Liste des tracks avec numérotation",
|
||||
"Boutons play / shuffle",
|
||||
"Actions par track (play, add to queue, remove)",
|
||||
"Boutons share / edit / delete (si owner)",
|
||||
"Playlist inexistante → 404",
|
||||
"Playlist privée d'un autre user → 403 ou 404",
|
||||
]),
|
||||
Route(19, "/playlists/:id/edit", "Édition playlist", "playlists",
|
||||
params="id",
|
||||
specific_checks=[
|
||||
"Formulaire pré-rempli (titre, description)",
|
||||
"Upload / changement de cover",
|
||||
"Réordonnement des tracks (drag & drop)",
|
||||
"Ajout / suppression de tracks",
|
||||
"Sauvegarde fonctionnelle",
|
||||
"Validation (titre requis)",
|
||||
"Accès interdit si pas owner → redirect ou 403",
|
||||
]),
|
||||
Route(20, "/tracks/:id", "Détail track", "playlists",
|
||||
params="id",
|
||||
specific_checks=[
|
||||
"Infos track (titre, artiste, album, durée, cover)",
|
||||
"Bouton play fonctionnel",
|
||||
"Boutons like / add to playlist / share",
|
||||
"Waveform / visualisation audio si présent",
|
||||
"Commentaires si présent",
|
||||
"Tracks similaires / recommandations",
|
||||
"Track inexistante → 404",
|
||||
"Métadonnées (genre, BPM, key si affichés)",
|
||||
]),
|
||||
|
||||
# ── Social & Communication ──
|
||||
Route(21, "/social", "Communauté", "social",
|
||||
specific_checks=[
|
||||
"Liste des utilisateurs / artistes",
|
||||
"Recherche / filtres",
|
||||
"Boutons follow / unfollow",
|
||||
"Profils cliquables",
|
||||
"Compteurs followers/following",
|
||||
"Sections (trending artists, new members, etc.)",
|
||||
]),
|
||||
Route(22, "/chat", "Messagerie", "social",
|
||||
test_with_accounts=["user", "creator"],
|
||||
specific_checks=[
|
||||
"Liste des conversations",
|
||||
"Envoi de message",
|
||||
"Réception en temps réel (WebSocket)",
|
||||
"Création de nouvelle conversation",
|
||||
"Recherche dans les conversations",
|
||||
"État vide (aucune conversation)",
|
||||
"Indicateurs read/unread",
|
||||
"Envoi de médias si supporté",
|
||||
]),
|
||||
Route(23, "/chat/join/:token", "Rejoindre un chat", "social",
|
||||
params="token",
|
||||
specific_checks=[
|
||||
"Token valide → rejoint le chat + redirect",
|
||||
"Token invalide → message d'erreur",
|
||||
"Token expiré → message d'erreur",
|
||||
"Déjà membre → redirect vers le chat existant",
|
||||
]),
|
||||
Route(24, "/live", "Streams en direct", "social",
|
||||
specific_checks=[
|
||||
"Liste des streams en cours",
|
||||
"Cartes stream (titre, artiste, viewers, thumbnail)",
|
||||
"Clic → page de visionnage",
|
||||
"État vide (aucun stream)",
|
||||
"Compteurs viewers",
|
||||
]),
|
||||
Route(25, "/live/go-live", "Lancer un stream", "social",
|
||||
role="creator",
|
||||
test_with_accounts=["creator", "admin"],
|
||||
specific_checks=[
|
||||
"Formulaire de configuration stream (titre, description, catégorie)",
|
||||
"Prévisualisation caméra/micro",
|
||||
"Bouton Go Live",
|
||||
"Vérification permissions (role creator/admin requis)",
|
||||
"Utilisateur standard → redirect ou message d'erreur",
|
||||
"Configuration RTMP/HLS si affichée",
|
||||
]),
|
||||
Route(26, "/listen-together/:sessionId", "Écoute collaborative", "social",
|
||||
params="sessionId",
|
||||
specific_checks=[
|
||||
"Session valide → rejoint l'écoute",
|
||||
"Session invalide → erreur",
|
||||
"Synchronisation audio entre participants",
|
||||
"Liste des participants",
|
||||
"Chat intégré si présent",
|
||||
"Contrôles de lecture (host only vs all)",
|
||||
]),
|
||||
|
||||
# ── Marketplace & Commerce ──
|
||||
Route(27, "/marketplace", "Place de marché", "marketplace",
|
||||
specific_checks=[
|
||||
"Grille / liste de produits",
|
||||
"Filtres (catégorie, prix, type)",
|
||||
"Recherche produits",
|
||||
"Cartes produits (image, titre, prix, vendeur)",
|
||||
"Pagination / infinite scroll",
|
||||
"Tri (prix, date, popularité)",
|
||||
]),
|
||||
Route(28, "/marketplace/products/:id", "Détail produit", "marketplace",
|
||||
params="id",
|
||||
specific_checks=[
|
||||
"Infos produit (images, titre, description, prix)",
|
||||
"Bouton acheter / ajouter au panier",
|
||||
"Avis / ratings si présent",
|
||||
"Infos vendeur",
|
||||
"Produit inexistant → 404",
|
||||
"Produits similaires",
|
||||
]),
|
||||
Route(29, "/wishlist", "Liste de souhaits", "marketplace",
|
||||
specific_checks=[
|
||||
"Liste des produits wishlistés",
|
||||
"Bouton retirer de la wishlist",
|
||||
"Bouton acheter directement",
|
||||
"État vide",
|
||||
"Infos produit à jour (prix, disponibilité)",
|
||||
]),
|
||||
Route(30, "/purchases", "Historique d'achats", "marketplace",
|
||||
specific_checks=[
|
||||
"Liste des achats passés",
|
||||
"Détails par achat (date, montant, produit, statut)",
|
||||
"Téléchargement si digital",
|
||||
"État vide",
|
||||
"Pagination si beaucoup d'achats",
|
||||
]),
|
||||
Route(31, "/checkout/complete", "Confirmation de commande", "marketplace",
|
||||
specific_checks=[
|
||||
"Message de confirmation",
|
||||
"Récapitulatif de la commande",
|
||||
"Liens vers les achats / téléchargements",
|
||||
"Comportement si accès direct sans commande → redirect",
|
||||
]),
|
||||
Route(32, "/sell", "Dashboard vendeur", "marketplace",
|
||||
role="creator",
|
||||
test_with_accounts=["creator", "admin"],
|
||||
specific_checks=[
|
||||
"Stats de vente (revenus, nombre de ventes)",
|
||||
"Liste des produits en vente",
|
||||
"Bouton ajouter un produit",
|
||||
"Gestion des produits (edit, delete, toggle visibility)",
|
||||
"Accès interdit pour les non-creators",
|
||||
]),
|
||||
|
||||
# ── Créateur & Analytics ──
|
||||
Route(33, "/analytics", "Tableau de bord analytics", "creator",
|
||||
role="creator",
|
||||
test_with_accounts=["creator", "admin"],
|
||||
specific_checks=[
|
||||
"Graphiques de stats (plays, listeners, revenue)",
|
||||
"Période sélectionnable (7j, 30j, 90j, 1an)",
|
||||
"Top tracks / Top playlists",
|
||||
"Données démographiques si présent",
|
||||
"Export de données si présent",
|
||||
"État vide pour un nouveau creator",
|
||||
"Accès interdit pour user standard",
|
||||
]),
|
||||
Route(34, "/cloud", "Stockage cloud", "creator",
|
||||
role="creator",
|
||||
test_with_accounts=["creator"],
|
||||
specific_checks=[
|
||||
"Explorateur de fichiers",
|
||||
"Upload de fichiers audio",
|
||||
"Progress bar upload",
|
||||
"Organisation en dossiers",
|
||||
"Actions (rename, delete, move, download)",
|
||||
"Quota de stockage affiché",
|
||||
"Types de fichiers acceptés / rejetés",
|
||||
]),
|
||||
Route(35, "/gear", "Inventaire équipement", "creator",
|
||||
role="creator",
|
||||
specific_checks=[
|
||||
"Liste de l'équipement",
|
||||
"Ajout / édition / suppression d'items",
|
||||
"Catégories (instruments, software, hardware)",
|
||||
"Photos d'équipement si supporté",
|
||||
"État vide",
|
||||
]),
|
||||
Route(36, "/distribution", "Distribution externe", "creator",
|
||||
role="creator",
|
||||
specific_checks=[
|
||||
"Liste des plateformes de distribution",
|
||||
"Statut de distribution par track/album",
|
||||
"Bouton distribuer",
|
||||
"Configuration des métadonnées requises",
|
||||
"Historique des distributions",
|
||||
]),
|
||||
|
||||
# ── Compte & Paramètres ──
|
||||
Route(37, "/profile", "Profil (redirect)", "account",
|
||||
specific_checks=[
|
||||
"Redirection vers /u/:username du user connecté",
|
||||
"Redirection correcte pour chaque rôle",
|
||||
]),
|
||||
Route(38, "/settings", "Paramètres du compte", "account",
|
||||
test_with_accounts=["user", "creator", "admin"],
|
||||
specific_checks=[
|
||||
"Onglet Account: changement de password fonctionnel",
|
||||
"Onglet Account: setup/disable 2FA (Authenticator + SMS)",
|
||||
"Onglet Account: statut 2FA correct",
|
||||
"Onglet Account: Delete Account (validation 'DELETE', password requis)",
|
||||
"Onglet Préférences: thème (radio buttons mutuellement exclusifs)",
|
||||
"Onglet Préférences: langue (changement effectif)",
|
||||
"Onglet Préférences: timezone",
|
||||
"Onglet Notifications: tous les toggles fonctionnels",
|
||||
"Onglet Confidentialité: tous les toggles fonctionnels",
|
||||
"Onglet Playback: audio quality, crossfade, etc.",
|
||||
"Bouton Save Config fonctionnel (pas d'erreur validation)",
|
||||
"Accessibilité: labels sur tous les checkboxes/toggles",
|
||||
"Pas de mélange i18n (tout FR ou tout EN)",
|
||||
"Pas de fuite d'infos (email dans search bar, VAPID key)",
|
||||
]),
|
||||
Route(39, "/settings/sessions", "Sessions actives", "account",
|
||||
specific_checks=[
|
||||
"Liste des sessions actives (device, IP, date)",
|
||||
"Session courante identifiée",
|
||||
"Bouton révoquer une session",
|
||||
"Bouton révoquer toutes les autres sessions",
|
||||
"Confirmation avant révocation",
|
||||
]),
|
||||
Route(40, "/notifications", "Centre de notifications", "account",
|
||||
specific_checks=[
|
||||
"Liste des notifications",
|
||||
"Marquage lu/non-lu",
|
||||
"Bouton marquer tout comme lu",
|
||||
"Filtres par type de notification",
|
||||
"Clic sur notification → navigation correcte",
|
||||
"État vide",
|
||||
"Pagination / infinite scroll",
|
||||
]),
|
||||
Route(41, "/subscription", "Abonnements & plans", "account",
|
||||
specific_checks=[
|
||||
"Affichage des plans disponibles",
|
||||
"Plan actuel mis en évidence",
|
||||
"Boutons upgrade / downgrade",
|
||||
"Comparaison des features par plan",
|
||||
"Historique de facturation si présent",
|
||||
"Gestion du moyen de paiement",
|
||||
]),
|
||||
|
||||
# ── Apprentissage & Support ──
|
||||
Route(42, "/education", "Formation & ressources", "learning",
|
||||
specific_checks=[
|
||||
"Liste des cours / ressources",
|
||||
"Catégories / filtres",
|
||||
"Progression si présent",
|
||||
"Contenu vidéo / texte se charge",
|
||||
"Liens fonctionnels",
|
||||
]),
|
||||
Route(43, "/support", "Support & aide", "learning",
|
||||
specific_checks=[
|
||||
"FAQ / base de connaissances",
|
||||
"Formulaire de contact / ticket",
|
||||
"Chat support si présent",
|
||||
"Recherche dans l'aide",
|
||||
"Liens vers documentation",
|
||||
]),
|
||||
|
||||
# ── Administration ──
|
||||
Route(44, "/admin", "Dashboard admin", "admin", role="admin",
|
||||
test_with_accounts=["admin"],
|
||||
specific_checks=[
|
||||
"Stats globales plateforme (users, tracks, revenue)",
|
||||
"Graphiques / KPIs",
|
||||
"Accès interdit pour non-admin → redirect ou 403",
|
||||
"Liens rapides vers sous-sections admin",
|
||||
]),
|
||||
Route(45, "/admin/moderation", "Modération contenu", "admin", role="admin",
|
||||
test_with_accounts=["admin", "moderator"],
|
||||
specific_checks=[
|
||||
"Queue de modération (signalements)",
|
||||
"Actions (approve, reject, ban)",
|
||||
"Filtres par type de contenu / statut",
|
||||
"Détail du signalement",
|
||||
"Historique des actions de modération",
|
||||
]),
|
||||
Route(46, "/admin/platform", "Administration plateforme", "admin", role="admin",
|
||||
test_with_accounts=["admin"],
|
||||
specific_checks=[
|
||||
"Configuration plateforme",
|
||||
"Gestion des features flags",
|
||||
"Stats système (storage, bandwidth)",
|
||||
"Logs si présent",
|
||||
]),
|
||||
Route(47, "/admin/transfers", "Gestion transferts/paiements", "admin", role="admin",
|
||||
test_with_accounts=["admin"],
|
||||
specific_checks=[
|
||||
"Liste des transferts / paiements",
|
||||
"Statuts (pending, completed, failed)",
|
||||
"Détail par transfert",
|
||||
"Actions (approve, reject, retry)",
|
||||
"Filtres et recherche",
|
||||
]),
|
||||
Route(48, "/admin/roles", "Gestion des rôles", "admin", role="admin",
|
||||
test_with_accounts=["admin"],
|
||||
specific_checks=[
|
||||
"Liste des rôles existants",
|
||||
"Permissions par rôle",
|
||||
"Assignation de rôles aux utilisateurs",
|
||||
"Création / modification de rôles",
|
||||
"Protection contre la suppression du rôle admin",
|
||||
]),
|
||||
|
||||
# ── Développeur ──
|
||||
Route(49, "/developer", "Dashboard développeur", "developer",
|
||||
specific_checks=[
|
||||
"Clés API (création, révocation, copie)",
|
||||
"Documentation API intégrée ou liens",
|
||||
"Stats d'utilisation API",
|
||||
"Webhooks configurés",
|
||||
"Rate limits affichés",
|
||||
]),
|
||||
Route(50, "/webhooks", "Gestion webhooks", "developer",
|
||||
specific_checks=[
|
||||
"Liste des webhooks configurés",
|
||||
"Ajout d'un webhook (URL, events, secret)",
|
||||
"Test de webhook",
|
||||
"Logs de delivery (succès/échec)",
|
||||
"Actions (edit, delete, toggle active)",
|
||||
]),
|
||||
|
||||
# ── Redirections ──
|
||||
Route(51, "/", "Redirect → /launch", "redirect", auth_required=False,
|
||||
specific_checks=[
|
||||
"Redirection automatique vers /launch",
|
||||
"Status code 301/302 approprié",
|
||||
"Pas de flash de contenu avant redirect",
|
||||
]),
|
||||
Route(52, "/tracks", "Redirect → /library", "redirect",
|
||||
specific_checks=[
|
||||
"Redirection automatique vers /library",
|
||||
"Fonctionne quand connecté",
|
||||
]),
|
||||
Route(53, "/community", "Redirect → /social", "redirect",
|
||||
specific_checks=[
|
||||
"Redirection automatique vers /social",
|
||||
]),
|
||||
Route(54, "/favorites", "Redirect → /playlists/favoris", "redirect",
|
||||
specific_checks=[
|
||||
"Redirection automatique vers /playlists/favoris",
|
||||
]),
|
||||
Route(55, "/home", "Redirect → /dashboard", "redirect",
|
||||
specific_checks=[
|
||||
"Redirection automatique vers /dashboard",
|
||||
]),
|
||||
|
||||
# ── Pages d'erreur ──
|
||||
Route(56, "/404", "Page non trouvée", "error", auth_required=False,
|
||||
specific_checks=[
|
||||
"Affichage du message 404",
|
||||
"Lien retour vers l'accueil",
|
||||
"Design cohérent avec le reste du site",
|
||||
"Pas d'erreurs console",
|
||||
]),
|
||||
Route(57, "/500", "Erreur serveur", "error", auth_required=False,
|
||||
specific_checks=[
|
||||
"Affichage du message d'erreur serveur",
|
||||
"Lien retour / bouton réessayer",
|
||||
"Pas de stack trace exposée",
|
||||
]),
|
||||
Route(58, "/random-nonexistent-path", "Catch-all → /404", "error", auth_required=False,
|
||||
specific_checks=[
|
||||
"Redirection vers la page 404",
|
||||
"URL arbitraire correctement interceptée",
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Prompt template
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def build_test_accounts_block() -> str:
|
||||
lines = []
|
||||
lines.append("Comptes de test :")
|
||||
lines.append("")
|
||||
lines.append("┌───────────┬───────────────────┬──────────────┐")
|
||||
lines.append("│ Rôle │ Email │ Mot de passe │")
|
||||
lines.append("├───────────┼───────────────────┼──────────────┤")
|
||||
for i, acc in enumerate(TEST_ACCOUNTS):
|
||||
role = acc["role"].ljust(9)
|
||||
email = acc["email"].ljust(17)
|
||||
pwd = acc["password"].ljust(12)
|
||||
lines.append(f"│ {role} │ {email} │ {pwd} │")
|
||||
if i < len(TEST_ACCOUNTS) - 1:
|
||||
lines.append("├───────────┼───────────────────┼──────────────┤")
|
||||
lines.append("└───────────┴───────────────────┴──────────────┘")
|
||||
lines.append(f"Domaine : {DOMAIN}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_prompt(route: Route) -> str:
|
||||
"""Génère le prompt Claude Code pour une route donnée."""
|
||||
|
||||
# Déterminer les comptes à utiliser
|
||||
if route.test_with_accounts:
|
||||
accounts_to_use = route.test_with_accounts
|
||||
elif route.role == "admin":
|
||||
accounts_to_use = ["admin"]
|
||||
elif route.role == "creator":
|
||||
accounts_to_use = ["creator"]
|
||||
elif not route.auth_required:
|
||||
accounts_to_use = ["(pas de connexion)", "user"]
|
||||
else:
|
||||
accounts_to_use = ["user", "creator"]
|
||||
|
||||
accounts_str = ", ".join(accounts_to_use)
|
||||
|
||||
# Checks spécifiques
|
||||
checks_block = ""
|
||||
if route.specific_checks:
|
||||
checks_lines = [f" - {c}" for c in route.specific_checks]
|
||||
checks_block = "\n".join(checks_lines)
|
||||
|
||||
# Paramètres dynamiques
|
||||
params_note = ""
|
||||
if route.params:
|
||||
params_note = f"""
|
||||
Note sur les paramètres dynamiques :
|
||||
Cette route utilise le paramètre `{route.params}`.
|
||||
- Teste avec une valeur VALIDE (existante en base)
|
||||
- Teste avec une valeur INVALIDE (inexistante) → comportement d'erreur attendu
|
||||
- Teste avec une valeur malformée (injection, caractères spéciaux)
|
||||
"""
|
||||
|
||||
prompt = f"""ok maintenant il faut dresser une liste exhaustive de toutes les erreurs qui existent dans la page "{route.name}" ({route.path}).
|
||||
Il faut tester chaque fonctionnalité une par une avec le MCP Playwright pour simuler un utilisateur réel.
|
||||
|
||||
{build_test_accounts_block()}
|
||||
|
||||
Comptes à utiliser pour cette page : {accounts_str}
|
||||
{params_note}
|
||||
─── INSTRUCTIONS ───
|
||||
|
||||
1. AUDIT EXHAUSTIF avec Playwright MCP :
|
||||
|
||||
Pour la page {route.path} ("{route.name}"), teste systématiquement :
|
||||
|
||||
a) CHARGEMENT & RENDU
|
||||
- La page se charge sans erreur (pas de crash, pas de blank screen)
|
||||
- Pas d'erreurs dans la console navigateur (JS errors, failed network requests)
|
||||
- Tous les éléments visuels attendus sont présents et visibles
|
||||
- Le layout est correct (pas d'overflow, pas d'éléments qui se chevauchent)
|
||||
|
||||
b) FONCTIONNALITÉS SPÉCIFIQUES À CETTE PAGE
|
||||
{checks_block}
|
||||
|
||||
c) RÉSEAU & API
|
||||
- Tous les appels API réussissent (pas de 4xx/5xx inattendus)
|
||||
- Les données affichées correspondent aux réponses API
|
||||
- Gestion correcte du loading state
|
||||
- Gestion correcte des erreurs réseau
|
||||
|
||||
d) SÉCURITÉ
|
||||
- Pas de fuite d'informations sensibles (tokens, emails dans l'URL, clés API)
|
||||
- Les données d'un autre utilisateur ne sont pas accessibles
|
||||
- Les actions protégées nécessitent bien l'authentification
|
||||
- CSRF / XSS : vérifier les inputs utilisateur
|
||||
{" - Vérifier que les rôles non-autorisés reçoivent bien un 403 ou redirect" if route.role != "user" else ""}
|
||||
|
||||
e) ACCESSIBILITÉ (a11y)
|
||||
- Tous les éléments interactifs ont un accessible name descriptif (pas juste "Checkbox" ou "Button")
|
||||
- Les labels sont associés aux inputs
|
||||
- Le focus order est logique (Tab navigation)
|
||||
- Les aria-labels/aria-describedby sont présents et pertinents
|
||||
- Contraste suffisant sur les textes
|
||||
|
||||
f) INTERNATIONALISATION (i18n)
|
||||
- Pas de mélange de langues (tout FR ou tout EN, pas les deux)
|
||||
- Les traductions sont complètes (pas de clés i18n brutes affichées)
|
||||
|
||||
g) RESPONSIVE
|
||||
- Tester en viewport mobile (375px), tablet (768px), desktop (1280px)
|
||||
- Pas d'éléments qui débordent
|
||||
- Navigation mobile fonctionnelle
|
||||
|
||||
2. FORMAT DU RAPPORT :
|
||||
|
||||
Produis un rapport structuré exactement comme suit :
|
||||
|
||||
```
|
||||
Rapport exhaustif des erreurs — Page {route.name} ({route.path})
|
||||
Testé avec le(s) compte(s) : {accounts_str}
|
||||
Date : [date du test]
|
||||
|
||||
───
|
||||
ERREURS CRITIQUES (bloquantes)
|
||||
|
||||
BUG #1 — [Titre court]
|
||||
- Sévérité: CRITIQUE
|
||||
- Section: [section de la page]
|
||||
- Repro: [étapes de reproduction]
|
||||
- Erreur: [description technique]
|
||||
- Source: [fichier:ligne si identifiable]
|
||||
- Impact: [impact utilisateur]
|
||||
|
||||
───
|
||||
ERREURS HAUTES
|
||||
|
||||
...
|
||||
|
||||
───
|
||||
ERREURS MOYENNES
|
||||
|
||||
...
|
||||
|
||||
───
|
||||
ERREURS FAIBLES
|
||||
|
||||
...
|
||||
|
||||
───
|
||||
RÉSUMÉ
|
||||
|
||||
┌────────────────────────────┬─────────┬──────────────┐
|
||||
│ Catégorie │ Nombre │ Sévérité max │
|
||||
├────────────────────────────┼─────────┼──────────────┤
|
||||
│ ... │ ... │ ... │
|
||||
└────────────────────────────┴─────────┴──────────────┘
|
||||
```
|
||||
|
||||
3. CORRECTION DES BUGS :
|
||||
|
||||
Après avoir dressé la liste, corrige TOUS les problèmes trouvés pour que la page soit
|
||||
entièrement fonctionnelle. Pour chaque fix :
|
||||
- Identifie le fichier source exact
|
||||
- Applique le correctif
|
||||
- Vérifie avec Playwright que le bug est résolu
|
||||
|
||||
4. TRANSFORMATION EN TESTS E2E :
|
||||
|
||||
Transforme toute la suite de vérification que tu viens de faire pour la page {route.path} en suite de
|
||||
tests Playwright que tu peux ajouter directement à ceux existants dans {E2E_TEST_DIR}.
|
||||
|
||||
Spécifications des tests :
|
||||
- Fichier : {E2E_TEST_DIR}tests/{_route_to_filename(route.path)}.spec.ts
|
||||
- Framework : Playwright Test (@playwright/test)
|
||||
- Pattern : Page Object Model si la page est complexe
|
||||
- Chaque bug trouvé ET corrigé = au moins 1 test de non-régression
|
||||
- Chaque fonctionnalité testée manuellement = 1 test automatisé
|
||||
- Tests organisés par describe() : "Chargement", "Fonctionnalités", "Sécurité", "a11y", "i18n"
|
||||
- Les tests doivent être indépendants (setup/teardown propre)
|
||||
- Utiliser les test accounts définis ci-dessus pour l'auth dans les fixtures
|
||||
|
||||
Structure attendue :
|
||||
```typescript
|
||||
import {{ test, expect }} from '@playwright/test';
|
||||
|
||||
test.describe('{route.name} ({route.path})', () => {{
|
||||
test.describe('Chargement & Rendu', () => {{
|
||||
test('la page se charge sans erreur', async ({{ page }}) => {{
|
||||
// ...
|
||||
}});
|
||||
}});
|
||||
|
||||
test.describe('Fonctionnalités', () => {{
|
||||
// Un test par fonctionnalité
|
||||
}});
|
||||
|
||||
test.describe('Sécurité', () => {{
|
||||
// Tests de sécurité
|
||||
}});
|
||||
|
||||
test.describe('Accessibilité', () => {{
|
||||
// Tests a11y
|
||||
}});
|
||||
|
||||
test.describe('Régression', () => {{
|
||||
// Un test par bug corrigé
|
||||
}});
|
||||
}});
|
||||
```
|
||||
|
||||
Assure-toi que les nouveaux tests s'intègrent avec la config Playwright existante
|
||||
et ne dupliquent pas des tests déjà présents.
|
||||
"""
|
||||
return prompt.strip()
|
||||
|
||||
|
||||
def _route_to_filename(path: str) -> str:
|
||||
"""Convertit un path comme /admin/moderation en admin-moderation."""
|
||||
clean = path.strip("/").replace("/", "-").replace(":", "")
|
||||
if not clean:
|
||||
clean = "root-redirect"
|
||||
return clean
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Output functions
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def write_single_prompt(route: Route, output_dir: str) -> str:
|
||||
"""Écrit un prompt dans un fichier et retourne le chemin."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
filename = f"{route.number:02d}-{_route_to_filename(route.path)}.md"
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
prompt = build_prompt(route)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(prompt)
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
def write_combined(routes: list[Route], output_dir: str) -> str:
|
||||
"""Écrit tous les prompts dans un seul fichier."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
filepath = os.path.join(output_dir, "ALL-PROMPTS.md")
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(f"# VEZA — Prompts d'audit exhaustif pour les {len(routes)} routes\n")
|
||||
f.write(f"# Généré le {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write(f"# Usage : copier-coller chaque section dans Claude Code une par une.\n\n")
|
||||
|
||||
for route in routes:
|
||||
f.write(f"\n{'='*80}\n")
|
||||
f.write(f"# ROUTE {route.number:02d}/{len(routes)} — {route.path} ({route.name})\n")
|
||||
f.write(f"{'='*80}\n\n")
|
||||
f.write(build_prompt(route))
|
||||
f.write("\n\n")
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
def list_routes(routes: list[Route]):
|
||||
"""Affiche toutes les routes."""
|
||||
categories = {}
|
||||
for r in routes:
|
||||
categories.setdefault(r.category, []).append(r)
|
||||
|
||||
cat_names = {
|
||||
"public": "Pages publiques",
|
||||
"main_nav": "Navigation principale",
|
||||
"playlists": "Playlists & Tracks",
|
||||
"social": "Social & Communication",
|
||||
"marketplace": "Marketplace & Commerce",
|
||||
"creator": "Créateur & Analytics",
|
||||
"account": "Compte & Paramètres",
|
||||
"learning": "Apprentissage & Support",
|
||||
"admin": "Administration",
|
||||
"developer": "Développeur",
|
||||
"redirect": "Redirections",
|
||||
"error": "Pages d'erreur",
|
||||
}
|
||||
|
||||
for cat, cat_routes in categories.items():
|
||||
print(f"\n ── {cat_names.get(cat, cat)} ──")
|
||||
for r in cat_routes:
|
||||
auth = "🔓" if not r.auth_required else f"🔒 {r.role}"
|
||||
print(f" {r.number:2d}. {r.path:<30s} {r.name:<35s} {auth}")
|
||||
|
||||
print(f"\n Total : {len(routes)} routes")
|
||||
|
||||
|
||||
def get_batch(routes: list[Route], batch_num: int, batch_size: int = 5) -> list[Route]:
|
||||
"""Retourne un sous-ensemble de routes par batch."""
|
||||
start = (batch_num - 1) * batch_size
|
||||
end = start + batch_size
|
||||
return routes[start:end]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# CLI
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="VEZA — Générateur de prompts Claude Code pour audit exhaustif",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Exemples :
|
||||
python veza-prompt-generator.py # Tous les prompts → ./prompts/
|
||||
python veza-prompt-generator.py --route /settings # Un seul prompt
|
||||
python veza-prompt-generator.py --route /admin # Un seul prompt
|
||||
python veza-prompt-generator.py --list # Liste les routes
|
||||
python veza-prompt-generator.py --batch 3 # Routes 11-15
|
||||
python veza-prompt-generator.py --batch 3 --size 10 # Routes 21-30
|
||||
python veza-prompt-generator.py --combined # Tout dans un fichier
|
||||
python veza-prompt-generator.py --category admin # Toutes les routes admin
|
||||
python veza-prompt-generator.py --out ./my-prompts # Dossier custom
|
||||
python veza-prompt-generator.py --print /settings # Affiche dans le terminal
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--route", type=str, help="Génère le prompt pour une route spécifique (ex: /settings)")
|
||||
parser.add_argument("--list", action="store_true", help="Liste toutes les routes")
|
||||
parser.add_argument("--batch", type=int, help="Numéro de batch (1-indexed)")
|
||||
parser.add_argument("--size", type=int, default=5, help="Taille d'un batch (défaut: 5)")
|
||||
parser.add_argument("--combined", action="store_true", help="Génère un seul fichier avec tous les prompts")
|
||||
parser.add_argument("--category", type=str, help="Filtre par catégorie (public, admin, social, ...)")
|
||||
parser.add_argument("--out", type=str, default=OUTPUT_DIR, help=f"Dossier de sortie (défaut: {OUTPUT_DIR})")
|
||||
parser.add_argument("--print", dest="print_route", type=str, help="Affiche le prompt dans le terminal au lieu de l'écrire")
|
||||
parser.add_argument("--critical-only", action="store_true", help="Uniquement les routes avec auth/rôle spécifique")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# ── List ──
|
||||
if args.list:
|
||||
list_routes(ROUTES)
|
||||
return
|
||||
|
||||
# ── Print single ──
|
||||
if args.print_route:
|
||||
route = next((r for r in ROUTES if r.path == args.print_route), None)
|
||||
if not route:
|
||||
print(f"❌ Route '{args.print_route}' non trouvée.", file=sys.stderr)
|
||||
print(" Utilise --list pour voir toutes les routes.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(build_prompt(route))
|
||||
return
|
||||
|
||||
# ── Single route ──
|
||||
if args.route:
|
||||
route = next((r for r in ROUTES if r.path == args.route), None)
|
||||
if not route:
|
||||
print(f"❌ Route '{args.route}' non trouvée.", file=sys.stderr)
|
||||
print(" Utilise --list pour voir toutes les routes.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
filepath = write_single_prompt(route, args.out)
|
||||
print(f"✅ Prompt généré : {filepath}")
|
||||
return
|
||||
|
||||
# ── Filter by category ──
|
||||
routes = ROUTES
|
||||
if args.category:
|
||||
routes = [r for r in ROUTES if r.category == args.category]
|
||||
if not routes:
|
||||
print(f"❌ Catégorie '{args.category}' non trouvée.", file=sys.stderr)
|
||||
cats = sorted(set(r.category for r in ROUTES))
|
||||
print(f" Catégories disponibles : {', '.join(cats)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.critical_only:
|
||||
routes = [r for r in routes if r.role != "user" or not r.auth_required]
|
||||
|
||||
# ── Batch ──
|
||||
if args.batch:
|
||||
routes = get_batch(routes, args.batch, args.size)
|
||||
if not routes:
|
||||
total_batches = (len(ROUTES) + args.size - 1) // args.size
|
||||
print(f"❌ Batch {args.batch} vide. Batches disponibles : 1-{total_batches}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"📦 Batch {args.batch} ({len(routes)} routes)")
|
||||
|
||||
# ── Combined ──
|
||||
if args.combined:
|
||||
filepath = write_combined(routes, args.out)
|
||||
print(f"✅ Fichier combiné généré : {filepath} ({len(routes)} prompts)")
|
||||
return
|
||||
|
||||
# ── All individual files ──
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
for route in routes:
|
||||
filepath = write_single_prompt(route, args.out)
|
||||
print(f" ✅ {route.number:2d}. {route.path:<30s} → {filepath}")
|
||||
|
||||
print(f"\n🎉 {len(routes)} prompts générés dans {args.out}/")
|
||||
print(f" Copie chaque prompt dans Claude Code un par un.")
|
||||
total_batches = (len(routes) + args.size - 1) // args.size
|
||||
print(f" Ou utilise --batch N (1-{total_batches}) pour y aller par groupes de {args.size}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue