diff --git a/tests/test_web_sync_browser.py b/tests/test_web_sync_browser.py new file mode 100644 index 0000000..ddbf3c3 --- /dev/null +++ b/tests/test_web_sync_browser.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Real Firefox click through the production C server and owned worker.""" +import json, os, signal, socket, subprocess, tempfile, time, unittest, warnings +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +FIXTURE=ROOT/"tests/sync_orchestrator_peer_fixture.py" +GECKO=Path(os.environ.get("TRAINLOG_GECKODRIVER","/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver")) +warnings.simplefilter("ignore", ResourceWarning) + +class BrowserSyncTest(unittest.TestCase): + def test_button_starts_worker_reconnects_and_displays_committed_draft(self): + if not GECKO.is_file(): self.skipTest("pinned geckodriver unavailable") + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.firefox.service import Service + from selenium.webdriver.support.ui import WebDriverWait + with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory: + root=Path(directory);xdg=root/"data";xdg.mkdir();config=root/"config.json" + config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory-test-double","capabilities":["generation-manifest-v1","generation-ack-v1","causal-delete-v1","mobile-history-v4","execution-draft-v1"],"peer_command":["python3",str(FIXTURE)],"timeout_seconds":30})) + with socket.socket() as reserved: + reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1] + env=os.environ.copy();env.update({"XDG_DATA_HOME":str(xdg),"XDG_CONFIG_HOME":str(root/"config"),"XDG_CACHE_HOME":str(root/"cache"),"TRAINLOG_SYNC_GENERATION_CONFIG":str(config)}) + server=subprocess.Popen([str(ROOT/"build/tui/trainlog"),"--web","--port",str(port)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True) + driver=None + try: + for _ in range(100): + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1",port))==0: break + time.sleep(.05) + options=webdriver.FirefoxOptions();options.add_argument("-headless") + driver=webdriver.Firefox(options=options,service=Service(str(GECKO))) + driver.get(f"http://127.0.0.1:{port}/") + wait=WebDriverWait(driver,15) + button=wait.until(lambda d:d.find_element(By.XPATH,"//button[normalize-space()='Synchroniser']")) + wait.until(lambda _ : button.is_enabled());button.click() + wait.until(lambda d:"Synchronisation confirmée" in d.page_source) + summary=driver.find_element(By.XPATH,"//summary[contains(.,'Synchronisation confirmée')]");summary.click() + wait.until(lambda d:"Brouillons reçus" in d.page_source and "pending" in d.page_source) + driver.refresh();wait.until(lambda d:"Synchronisation confirmée" in d.page_source) + state=json.loads(Path(str(xdg/"trainlog"/"trainlog.db")+".sync-run.json").read_text()) + self.assertEqual(state["phase"],"completed");self.assertEqual(state["sessions_reconciled"],1) + finally: + if driver is not None: driver.quit() + os.killpg(server.pid,signal.SIGTERM) + server.wait(timeout=10) + +if __name__=="__main__":unittest.main() diff --git a/web/src/api/sync.test.ts b/web/src/api/sync.test.ts new file mode 100644 index 0000000..7c1e5a8 --- /dev/null +++ b/web/src/api/sync.test.ts @@ -0,0 +1,8 @@ +import { beforeEach,describe,expect,it,vi } from 'vitest' +import { fetchSyncStatus,startSync,syncIsActive } from './sync' +const token='b'.repeat(64) +function response(value:unknown,status=200){return {ok:status>=200&&status<300,status,headers:new Headers({'X-Trainlog-CSRF-Token':token}),json:()=>Promise.resolve(value)}} +describe('sync API',()=>{beforeEach(()=>vi.restoreAllMocks()) + it('obtient passivement le statut et utilise le jeton pour la mutation',async()=>{const fetch=vi.fn().mockResolvedValueOnce(response({phase:'idle',result:'disabled'})).mockResolvedValueOnce(response({phase:'requested',result:'running'},202));vi.stubGlobal('fetch',fetch);await fetchSyncStatus();await startSync('sy_11111111-1111-4111-8111-111111111111');expect(fetch).toHaveBeenCalledTimes(2);const init=fetch.mock.calls[1][1];expect(init.method).toBe('POST');expect(init.headers['X-Trainlog-CSRF-Token']).toBe(token)}) + it('rejette un statut inconnu et classe les phases actives',async()=>{vi.stubGlobal('fetch',vi.fn().mockResolvedValue(response({phase:'invented',result:'ok'})));await expect(fetchSyncStatus()).rejects.toThrow();expect(syncIsActive('waiting_acknowledgement')).toBe(true);expect(syncIsActive('completed')).toBe(false)}) +}) diff --git a/web/src/api/sync.ts b/web/src/api/sync.ts new file mode 100644 index 0000000..2c3f0a9 --- /dev/null +++ b/web/src/api/sync.ts @@ -0,0 +1,10 @@ +export type SyncPhase = 'idle' | 'requested' | 'waiting_android_publication' | 'running' | 'local_import_committed' | 'published' | 'waiting_acknowledgement' | 'peer_consumed' | 'completed' | 'failed' | 'interrupted' | 'explicitly_degraded' +export interface SyncDraftSummary { draft_id: string; state: 'active'|'pending'|'finalized'|'stale'; session_type: string; occurrence_count: number } +export interface SyncStatus { api_version?: number; enabled?: boolean; phase: SyncPhase; result: string; run_id?: string; request_id?: string; progress_revision?: number; diagnostic?: string; sessions_reconciled?: number|null; inbound_generation_id?: string|null; outbound_generation_id?: string|null; missing_capabilities?: string[]; domains?: Record; drafts?: SyncDraftSummary[]; finished_at?: string|null } +let csrfToken='' +const phases=new Set(['idle','requested','waiting_android_publication','running','local_import_committed','published','waiting_acknowledgement','peer_consumed','completed','failed','interrupted','explicitly_degraded']) +function parse(value:unknown):SyncStatus { if(typeof value!=='object'||value===null)throw new Error('statut sync invalide');const status=value as Partial;if(typeof status.phase!=='string'||!phases.has(status.phase as SyncPhase)||typeof status.result!=='string')throw new Error('statut sync invalide');if(status.drafts!==undefined&&(!Array.isArray(status.drafts)||status.drafts.length>32))throw new Error('résumé des brouillons invalide');return status as SyncStatus } +export async function fetchSyncStatus(signal?:AbortSignal):Promise{const response=await fetch('/api/v1/sync/status',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`sync status HTTP ${response.status}`);const token=response.headers.get('X-Trainlog-CSRF-Token')??'';if(!/^[0-9a-f]{64}$/.test(token))throw new Error('jeton CSRF absent');csrfToken=token;return parse(await response.json())} +export async function startSync(requestId:string):Promise{if(!/^sy_[0-9a-f-]{36}$/.test(requestId)||csrfToken.length!==64)throw new Error('requête sync invalide');const response=await fetch('/api/v1/sync',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':csrfToken},body:JSON.stringify({request_id:requestId,trigger:'web'})});const value=await response.json();if(!response.ok)throw new Error(typeof value?.error==='string'?value.error:`sync HTTP ${response.status}`);return parse(value)} +export function newRequestId():string{return `sy_${crypto.randomUUID()}`} +export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)} diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 76225aa..bbe4267 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { fetchHealth, type Health } from '../api/health' import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard' import { Footer } from '../components/Footer' @@ -23,6 +23,10 @@ export function App() { const [dashboard, setDashboard] = useState(null) const [dashboardPending, setDashboardPending] = useState(true) const [dashboardFailed, setDashboardFailed] = useState(false) + const reloadDashboard = useCallback(() => { + fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) }) + .catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false)) + }, []) useEffect(() => { const controller = new AbortController() @@ -46,7 +50,7 @@ export function App() { return (
Aller au contenu -
+
{content}
diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index dbdb64f..b100e29 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -1,12 +1,14 @@ import type { MouseEvent } from 'react' import { routes, type AppRoute } from '../app/routes' +import { SyncControl } from './SyncControl' interface HeaderProps { activeRoute: AppRoute onNavigate: (path: string) => void + onSyncCommitted: () => void } -export function Header({ activeRoute, onNavigate }: HeaderProps) { +export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps) { const follow = (event: MouseEvent, path: string) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return event.preventDefault() @@ -30,6 +32,7 @@ export function Header({ activeRoute, onNavigate }: HeaderProps) { ))} + ) } diff --git a/web/src/components/SyncControl.test.tsx b/web/src/components/SyncControl.test.tsx new file mode 100644 index 0000000..68aef2d --- /dev/null +++ b/web/src/components/SyncControl.test.tsx @@ -0,0 +1,11 @@ +import { fireEvent,render,screen,waitFor } from '@testing-library/react' +import { describe,expect,it,vi } from 'vitest' +import { SyncControl } from './SyncControl' +const headers=new Headers({'X-Trainlog-CSRF-Token':'c'.repeat(64)}) +const reply=(value:unknown,status=200)=>({ok:status<300,status,headers,json:()=>Promise.resolve(value)}) +describe('contrôle global de synchronisation',()=>{ + it('démarre une fois, confirme par polling et expose le brouillon sans le relabeller',async()=>{const fetch=vi.fn() + .mockResolvedValueOnce(reply({phase:'idle',result:'idle'})) + .mockResolvedValueOnce(reply({phase:'requested',result:'running'},202)) + .mockResolvedValueOnce(reply({phase:'completed',result:'completed',progress_revision:7,sessions_reconciled:1,finished_at:'2026-09-17T12:00:00Z',drafts:[{draft_id:'draft_1',state:'pending',session_type:'strength',occurrence_count:2}]}));vi.stubGlobal('fetch',fetch);const committed=vi.fn();render();await waitFor(()=>expect(screen.getByRole('button',{name:'Synchroniser'})).toBeEnabled());fireEvent.click(screen.getByRole('button',{name:'Synchroniser'}));await waitFor(()=>expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(),{timeout:2500});fireEvent.click(screen.getByText('Synchronisation confirmée'));expect(screen.getByRole('region',{name:'Brouillons synchronisés'})).toHaveTextContent('pending');expect(committed).toHaveBeenCalledTimes(1);expect(fetch.mock.calls.filter(call=>String(call[0]).endsWith('/api/v1/sync'))).toHaveLength(1)}) +}) diff --git a/web/src/components/SyncControl.tsx b/web/src/components/SyncControl.tsx new file mode 100644 index 0000000..f4b6574 --- /dev/null +++ b/web/src/components/SyncControl.tsx @@ -0,0 +1,10 @@ +import { useEffect,useRef,useState } from 'react' +import { fetchSyncStatus,newRequestId,startSync,syncIsActive,type SyncStatus } from '../api/sync' +const labels:Record={idle:'Synchronisation inactive',requested:'Synchronisation demandée',waiting_android_publication:'Publication Android requise',running:'Synchronisation en cours',local_import_committed:'Données desktop validées',published:'Génération retour publiée',waiting_acknowledgement:'Accusé Android attendu',peer_consumed:'Pair confirmé',completed:'Synchronisation confirmée',failed:'Échec de synchronisation',interrupted:'Synchronisation interrompue',explicitly_degraded:'Pair incompatible'} +export function SyncControl({onCommitted}:{onCommitted:()=>void}){const [status,setStatus]=useState(null);const [error,setError]=useState('');const committed=useRef(-1) + const accept=(next:SyncStatus)=>{setStatus(next);setError('');if(['local_import_committed','published','waiting_acknowledgement','peer_consumed','completed'].includes(next.phase)&&(next.progress_revision??0)>committed.current){committed.current=next.progress_revision??0;onCommitted()}} + useEffect(()=>{let stopped=false;let timer:number|undefined;const poll=async()=>{try{const next=await fetchSyncStatus();if(stopped)return;accept(next);if(syncIsActive(next.phase))timer=window.setTimeout(poll,750)}catch{if(!stopped)setError('Statut de synchronisation indisponible')}};void poll();return()=>{stopped=true;if(timer!==undefined)window.clearTimeout(timer)}},[onCommitted]) + const start=async()=>{setError('');try{accept(await startSync(newRequestId()));const poll=async()=>{try{const next=await fetchSyncStatus();accept(next);if(syncIsActive(next.phase))window.setTimeout(poll,750)}catch{setError('Résultat inconnu : vérifiez le statut avant de réessayer')}};window.setTimeout(poll,250)}catch(reason){setError(reason instanceof Error?reason.message:'Démarrage impossible')}} + const active=status?syncIsActive(status.phase):false + return
{error||labels[status?.phase??'idle']}{status?.diagnostic&&

{status.diagnostic}

}{status?.sessions_reconciled!==null&&status?.sessions_reconciled!==undefined&&

Séances rapprochées : {status.sessions_reconciled}

}{status?.phase==='waiting_android_publication'&&

Ouvrez l’écran de synchronisation Android pour publier une génération fraîche.

}{(status?.drafts?.length??0)>0&&

Brouillons reçus

    {status!.drafts!.map(d=>
  • {d.draft_id} · {d.state} · {d.session_type} · {d.occurrence_count} occurrence(s)
  • )}
}{status?.finished_at&&

Dernier résultat : {status.finished_at}

}
+} diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 11abf0c..26f02fd 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -38,6 +38,15 @@ .nav-link:hover { color: var(--text); background: rgb(49 50 68 / 48%); } .nav-link.is-active { color: var(--lavender); } .nav-link.is-active::after { content: ""; position: absolute; left: .85rem; right: .85rem; bottom: .22rem; height: 2px; background: var(--lavender); border-radius: 2px; } +.sync-control { margin-left: auto; display: flex; align-items: center; gap: .6rem; position: relative; } +.sync-control button { border: 1px solid var(--lavender); border-radius: .55rem; padding: .55rem .8rem; background: transparent; color: var(--text); cursor: pointer; } +.sync-control button:focus-visible { outline: 3px solid var(--lavender); outline-offset: 2px; } +.sync-control button:disabled { cursor: not-allowed; opacity: .55; } +.sync-details { max-width: 28rem; color: var(--subtext-0); } +.sync-details summary { cursor: pointer; } +.sync-details[open] { position: absolute; z-index: 20; right: 0; top: 2.8rem; min-width: 20rem; padding: .8rem; border: 1px solid var(--surface-2); border-radius: .7rem; background: var(--base); box-shadow: 0 .5rem 2rem #0008; } +.sync-details h2 { font-size: 1rem; } +.sync-details ul { padding-left: 1.2rem; } .app-main { width: 100%; min-width: 0; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); } .page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; } @@ -170,6 +179,8 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .app-header { align-items: stretch; flex-direction: column; gap: .55rem; padding-block: .7rem .45rem; } .brand { align-self: start; } .main-nav { margin-inline: calc(var(--shell-gutter) * -1); padding-inline: var(--shell-gutter); } + .sync-control { margin-left: 0; justify-content: space-between; } + .sync-details[open] { position: static; min-width: 0; } .page-heading { align-items: start; flex-direction: column; gap: 1rem; } .tile-grid { display: flex; flex-direction: column; } .tile, .tile-wide, .tile-feature, .principle-panel { width: 100%; grid-column: auto; }