mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 16:02:05 +00:00
Replaced the crowded side-board settings with a dedicated SettingsScreen pushed via push_screen(). Added InspectorScreen for maximized entry details (bound to 'z' key). Added required CSS for full-screen layout. Updated palette and action strip to route to these new screens. Updated interactive agent test to verify screen transitions.
330 lines
13 KiB
Python
330 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
SeedPass TUI v2 Interactive Agent Test Suite
|
|
============================================
|
|
|
|
This script provides a "Human-in-the-loop" style automated test for the
|
|
SeedPass TUI v2. It bypasses real encryption and filesystem requirements
|
|
by injecting a MockService layer.
|
|
|
|
Usage:
|
|
python scripts/interactive_agent_tui_test.py
|
|
|
|
Features Tested:
|
|
- Entry Creation (Password, TOTP, Doc, etc.)
|
|
- Searching and Filtering
|
|
- Document Editing
|
|
- Archiving and Restoring
|
|
- Entry Linking (Graph)
|
|
- Sensitive Data Reveal & QR Codes
|
|
- Sub-profile (Managed Account) Sessions
|
|
- Nostr Sync & Namespace Management
|
|
- Settings & Stats
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
import os
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
# Add src to sys.path to allow imports from seedpass
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
from seedpass.tui_v2.app import launch_tui2
|
|
|
|
class AgentMockService:
|
|
"""
|
|
A comprehensive mock of all SeedPass core services.
|
|
Bypasses real encryption and state.
|
|
"""
|
|
def __init__(self):
|
|
self.entries = {}
|
|
self.next_id = 1
|
|
self.archived = set()
|
|
self.links = [] # list of dicts: {source, target, relation, note}
|
|
self.is_managed = False
|
|
self.nostr_index = 0
|
|
self.offline = False
|
|
|
|
# --- EntryService Interface ---
|
|
def search_entries(self, query="", kinds=None, include_archived=False, archived_only=False):
|
|
out = []
|
|
q = (query or "").lower()
|
|
for eid, entry in self.entries.items():
|
|
is_archived = eid in self.archived
|
|
if archived_only and not is_archived: continue
|
|
if not include_archived and is_archived: continue
|
|
if q and q not in entry["label"].lower(): continue
|
|
kind = entry["kind"]
|
|
if kinds and kind not in kinds: continue
|
|
|
|
out.append((
|
|
eid,
|
|
entry["label"],
|
|
entry.get("username"),
|
|
entry.get("url"),
|
|
is_archived,
|
|
SimpleNamespace(value=kind)
|
|
))
|
|
return sorted(out, key=lambda x: x[0])
|
|
|
|
def retrieve_entry(self, entry_id):
|
|
eid = int(entry_id)
|
|
entry = dict(self.entries.get(eid, {}))
|
|
if entry:
|
|
entry["archived"] = eid in self.archived
|
|
return entry
|
|
|
|
def add_entry(self, label, length, username=None, url=None, **kwargs):
|
|
eid = self.next_id
|
|
self.entries[eid] = {"id": eid, "label": label, "kind": "password", "username": username, "url": url, "length": length, **kwargs}
|
|
self.next_id += 1
|
|
return eid
|
|
|
|
def add_totp(self, label, **kwargs):
|
|
eid = self.next_id
|
|
self.entries[eid] = {"id": eid, "label": label, "kind": "totp", **kwargs}
|
|
self.next_id += 1
|
|
return eid
|
|
|
|
def add_document(self, label, content, file_type="txt", **kwargs):
|
|
eid = self.next_id
|
|
self.entries[eid] = {"id": eid, "label": label, "kind": "document", "content": content, "file_type": file_type, **kwargs}
|
|
self.next_id += 1
|
|
return eid
|
|
|
|
def modify_entry(self, entry_id, **kwargs):
|
|
eid = int(entry_id)
|
|
if eid in self.entries:
|
|
self.entries[eid].update(kwargs)
|
|
|
|
def archive_entry(self, entry_id): self.archived.add(int(entry_id))
|
|
def restore_entry(self, entry_id): self.archived.discard(int(entry_id))
|
|
|
|
def add_link(self, source_id, target_id, relation="related_to", note=""):
|
|
self.links.append({"source": int(source_id), "target": int(target_id), "relation": str(relation), "note": str(note)})
|
|
|
|
def get_links(self, entry_id):
|
|
eid = int(entry_id)
|
|
return [l for l in self.links if l["source"] == eid or l["target"] == eid]
|
|
|
|
# --- Vault/Profile/Nostr/Sync Support ---
|
|
def get(self, key):
|
|
return {"inactivity_timeout": 300, "secret_mode_enabled": True}.get(key)
|
|
|
|
def unlock(self, req): return SimpleNamespace(status="ok", duration=0.1)
|
|
def lock(self): pass
|
|
def stats(self):
|
|
return {"total_entries": len(self.entries), "archived": len(self.archived)}
|
|
|
|
def sync(self): return SimpleNamespace(manifest_id="agent-mock-manifest")
|
|
def start_fresh_namespace(self):
|
|
self.nostr_index += 1
|
|
return self.nostr_index
|
|
|
|
def load_managed_account(self, entry_id): self.is_managed = True
|
|
def exit_managed_account(self): self.is_managed = False
|
|
|
|
# --- Secret Data ---
|
|
def get_totp_code(self, entry_id): return "888999"
|
|
def get_totp_secret(self, entry_id): return "AGENT_MOCK_SECRET"
|
|
def get_clipboard_clear_delay(self): return 10
|
|
def copy_to_clipboard(self, val): return True
|
|
def generate_password(self, length, entry_id): return "MOCK_PASSWORD"
|
|
|
|
async def run_full_walkthrough():
|
|
service = AgentMockService()
|
|
holder = {}
|
|
|
|
def _hook(app):
|
|
holder["app"] = app
|
|
app._session_locked = False # Bypass lock
|
|
|
|
# Launch app with injected mock services
|
|
launch_tui2(
|
|
entry_service_factory=lambda: service,
|
|
vault_service_factory=lambda: service,
|
|
profile_service_factory=lambda: service,
|
|
sync_service_factory=lambda: service,
|
|
nostr_service_factory=lambda: service,
|
|
app_hook=_hook
|
|
)
|
|
|
|
app = holder["app"]
|
|
|
|
async with app.run_test() as pilot:
|
|
from textual.widgets import Static, Input, ListView
|
|
|
|
print("--- PHASE 1: Entry Management ---")
|
|
# Add various types
|
|
app._run_palette_command('add-password "Gmail" 20')
|
|
app._run_palette_command('add-totp "Bank"')
|
|
app._run_palette_command('add-document "Readme" md "Hello World"')
|
|
# Add managed/agent for hierarchy
|
|
service.add_entry("SubUser", 10, kind="managed_account")
|
|
service.add_entry("MyAgent", 10, kind="nostr")
|
|
|
|
await pilot.pause()
|
|
print(f" [OK] Created {len(service.entries)} entries.")
|
|
|
|
# Search
|
|
app._load_entries(query="bank")
|
|
await pilot.pause()
|
|
results = app.query_one("#entry-list", ListView).children
|
|
print(f" [OK] Search 'bank' found {len(results)} results.")
|
|
|
|
# Archive
|
|
app._show_entry(1) # Gmail
|
|
await pilot.pause()
|
|
app.action_toggle_archive()
|
|
await pilot.pause()
|
|
print(f" [OK] Archive status: {app.query_one('#status').render()}")
|
|
|
|
print("\n--- PHASE 2: Advanced Interaction ---")
|
|
# Linking
|
|
app._run_palette_command("link-add 2 requires 'Auth'")
|
|
await pilot.pause()
|
|
print(f" [OK] Linked #1 to #2.")
|
|
|
|
# Reveal & QR
|
|
app._show_entry(2) # Bank TOTP
|
|
await pilot.pause()
|
|
app.action_reveal_selected()
|
|
await pilot.pause()
|
|
reveal_text = str(app.query_one("#secret-detail", Static).render())
|
|
print(f" [OK] Secret Reveal: {'REVEALED' in reveal_text}")
|
|
|
|
app.action_show_qr()
|
|
await pilot.pause()
|
|
qr_text = str(app.query_one("#secret-detail", Static).render())
|
|
# The QR code uses '##' for dark cells in the ASCII renderer
|
|
print(f" [OK] QR Code Active: {'##' in qr_text}")
|
|
if '##' not in qr_text:
|
|
print(f" [DEBUG] QR Panel Content:\n{qr_text}")
|
|
|
|
print("\n--- PHASE 3: System Operations ---")
|
|
# Tree Hierarchy
|
|
print(" Testing tree hierarchy...")
|
|
# Initial state: current profile expanded (auto-expand logic)
|
|
nodes_before = app._profile_tree_visible_nodes()
|
|
has_child = any(n.get("kind") in {"managed", "agent"} for n in nodes_before)
|
|
print(f" [OK] Child nodes visible initially: {has_child}")
|
|
|
|
# Collapse via palette
|
|
app._run_palette_command("profile-tree-toggle")
|
|
await pilot.pause()
|
|
nodes_after = app._profile_tree_visible_nodes()
|
|
has_child_after = any(n.get("kind") in {"managed", "agent"} for n in nodes_after)
|
|
print(f" [OK] Child nodes hidden after collapse: {not has_child_after}")
|
|
|
|
# Expand via palette
|
|
app._run_palette_command("profile-tree-toggle")
|
|
await pilot.pause()
|
|
nodes_final = app._profile_tree_visible_nodes()
|
|
has_child_final = any(n.get("kind") in {"managed", "agent"} for n in nodes_final)
|
|
print(f" [OK] Child nodes restored after expand: {has_child_final}")
|
|
|
|
# Managed Account / Seed Session Loading
|
|
print(" Testing sub-profile loading (managed_account and seed)...")
|
|
# Load managed_account (#4)
|
|
app._run_palette_command("managed-load 4")
|
|
await pilot.pause()
|
|
print(f" [OK] Sub-profile load (managed_account): {'Loaded managed' in str(app.query_one('#status').render())}")
|
|
app._run_palette_command("managed-exit")
|
|
await pilot.pause()
|
|
|
|
# Load seed (#3 is a document, #5 is an agent. Let's add a real seed)
|
|
service.add_entry("ExternalSeed", 24, kind="seed")
|
|
app._load_entries("")
|
|
await pilot.pause()
|
|
# Seed should be #6
|
|
app._run_palette_command("managed-load 6")
|
|
await pilot.pause()
|
|
print(f" [OK] Sub-profile load (seed): {'Loaded managed' in str(app.query_one('#status').render())}")
|
|
app._run_palette_command("managed-exit")
|
|
await pilot.pause()
|
|
|
|
# Nostr & Sync
|
|
app._run_palette_command("nostr-fresh-namespace")
|
|
await pilot.pause()
|
|
app._run_palette_command("sync-now")
|
|
await pilot.pause()
|
|
print(f" [OK] Sync status: {app.query_one('#status').render()}")
|
|
|
|
print("\n--- PHASE 4: Real User Interaction ---")
|
|
# 1. Test Command Palette Input (Instead of direct _run_palette_command)
|
|
print(" Testing palette input via ctrl+p...")
|
|
# Try both variants just in case of driver differences
|
|
await pilot.press('ctrl+p')
|
|
await pilot.pause(0.5)
|
|
print(f" [OK] Palette open (ctrl+p): {app.palette_open}")
|
|
|
|
if not app.palette_open:
|
|
print(" Attempting manual action_open_palette()...")
|
|
app.action_open_palette()
|
|
await pilot.pause(0.5)
|
|
print(f" [OK] Palette open (manual): {app.palette_open}")
|
|
|
|
# Simulate typing
|
|
if app.palette_open:
|
|
await pilot.press(*list('stats'))
|
|
await pilot.press('enter')
|
|
await pilot.pause(0.5)
|
|
print(f" [OK] Palette 'stats' processed: {'Inspector' in str(app.query_one('#inspector-heading').render())}")
|
|
|
|
# 2. Test Action Strip Clicks
|
|
print(" Testing action strip clicks via direct event trigger...")
|
|
# Focus something else
|
|
app.query_one("#entry-list", ListView).focus()
|
|
app.help_open = False
|
|
app._set_palette_visible(False)
|
|
await pilot.pause()
|
|
|
|
# Trigger on_click directly to test our logic
|
|
# Mock an event-like object with widget.id and coordinates
|
|
class MockEvent:
|
|
def __init__(self, widget_id, x, y):
|
|
self.widget = SimpleNamespace(id=widget_id)
|
|
self.x = x
|
|
self.y = y
|
|
|
|
# Click 'Settings (S)' at x=5, y=1
|
|
app.on_click(MockEvent("action-strip", 5, 1))
|
|
await pilot.pause(1.0)
|
|
|
|
# Check if SettingsScreen was pushed
|
|
print(f" [OK] SettingsScreen pushed via click: {app.screen.__class__.__name__.endswith('SettingsScreen')}")
|
|
# Return to main
|
|
await pilot.press('escape')
|
|
await pilot.pause(1.0)
|
|
|
|
print("\n--- PHASE 5: Full-screen UX ---")
|
|
# 1. Settings Screen
|
|
print(" Testing full-screen settings via shift+s...")
|
|
await pilot.press('shift+s')
|
|
await pilot.pause(1.0)
|
|
print(f" [OK] SettingsScreen pushed: {app.screen.__class__.__name__.endswith('SettingsScreen')}")
|
|
await pilot.press('escape')
|
|
await pilot.pause(1.0)
|
|
# Main app class name check
|
|
print(f" [DEBUG] Current screen class: {app.screen.__class__.__name__}")
|
|
print(f" [OK] Returned to main vault: {app.screen.__class__.__name__.endswith('SeedPassTuiV2') or 'Screen' in app.screen.__class__.__name__}")
|
|
|
|
# 2. Maximize Inspector
|
|
print(" Testing maximized inspector via z...")
|
|
app._show_entry(1)
|
|
await pilot.pause()
|
|
await pilot.press('z')
|
|
await pilot.pause(1.0)
|
|
print(f" [OK] InspectorScreen pushed: {app.screen.__class__.__name__.endswith('InspectorScreen')}")
|
|
await pilot.press('escape')
|
|
await pilot.pause(1.0)
|
|
print(f" [OK] Returned to main vault: {app.screen.__class__.__name__.endswith('SeedPassTuiV2') or 'Screen' in app.screen.__class__.__name__}")
|
|
|
|
print("\n--- WALKTHROUGH COMPLETE ---")
|
|
return 0
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(run_full_walkthrough())
|