diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py index c1e80a41..eb376a5e 100644 --- a/app/_launch_runtime.py +++ b/app/_launch_runtime.py @@ -121,7 +121,7 @@ from services.generation import bind_wgp, get_model_def bind_wgp(wgp) from services import model3d_service, minimax_h3_service, minimax_image_service -from services import tools_upscale +from services import tools_upscale as tools_upscale_service from services import debug_trace from routers.lan_auth import create_lan_auth_router from services.durable_generation_queue import DurableGenerationQueue @@ -22861,8 +22861,8 @@ def _apply_spatial_upsampling_to_file(video_path: str, method: str, job: dict = # Compatibility aliases keep the existing HTTP validation contract stable while # the implementation lives in the standalone Tools service. -_TOOL_UPSCALE_METHODS = tools_upscale.TOOL_UPSCALE_METHODS -_TOOL_SOURCE_EXTENSIONS = tools_upscale.TOOL_SOURCE_EXTENSIONS +_TOOL_UPSCALE_METHODS = tools_upscale_service.TOOL_UPSCALE_METHODS +_TOOL_SOURCE_EXTENSIONS = tools_upscale_service.TOOL_SOURCE_EXTENSIONS def _tool_asset_roots() -> list[dict[str, str]]: @@ -23062,7 +23062,7 @@ def _upscale_tool_image( progress_callback=None, ) -> tuple[int, int]: """Compatibility facade for callers that used the old launch symbol.""" - return tools_upscale.upscale_image( + return tools_upscale_service.upscale_image( source_path, output_path, method, @@ -23147,7 +23147,7 @@ def _write_tool_sidecar( def _run_tool_upscale(job_id: str): """Compatibility facade for the standalone Tools upscale service.""" - return tools_upscale.run_tool_upscale( + return tools_upscale_service.run_tool_upscale( job_id, runtime={ "jobs": _jobs, diff --git a/app/services/job_lifecycle.py b/app/services/job_lifecycle.py index 43104527..ab137ebf 100644 --- a/app/services/job_lifecycle.py +++ b/app/services/job_lifecycle.py @@ -629,6 +629,11 @@ def acquire_generation_slot( _remove_generation_waiter(lock_key, token, job) return False queue = _generation_queues.get(lock_key) + # A worker can fail before entering this function. Cancellation + # must let its successors advance even if that worker never polls. + while queue and is_cancel_requested(queue[0][2]): + _, cancelled_token, cancelled_job = queue[0] + _remove_generation_waiter(lock_key, cancelled_token, cancelled_job) is_head = bool(queue and queue[0][1] is token) if not is_head: _generation_queue_condition.wait(timeout=poll_interval) diff --git a/docs/APP_ACCEPTANCE_COVERAGE.md b/docs/APP_ACCEPTANCE_COVERAGE.md new file mode 100644 index 00000000..3a3c3d33 --- /dev/null +++ b/docs/APP_ACCEPTANCE_COVERAGE.md @@ -0,0 +1,68 @@ +# Auditoría de aplicación — 8 de septiembre de 2026 + +Base de implementación: `92c95500` de `development`. Los medios, conversaciones +y trazas de ejecución se conservan fuera de Git, en +`outputs/app-acceptance-20260908`. El índice generado enlaza todos los intentos, +incluidos los fallidos; no convierte una repetición posterior en éxito histórico. + +## Qué se ha observado + +| Caso | Evidencia y alcance | +| --- | --- | +| Inventario de interfaz | 52 destinos y submodos con captura individual; última pasada sin errores de página. Incluye móvil de 390 × 844. | +| Imagen nativa | Flux 2 Klein 9B real; tarea canónica completada, JPG descargado, identidad y metadatos reales. | +| Upscale | La imagen anterior ampliada con Lanczos ×2; dimensiones decodificadas exactamente dobles. | +| Música nativa | ACE-Step 1.5 XL SFT LM 4B real; WAV de 30 segundos, duración comprobada en navegador y decodificación completa con FFmpeg. | +| Wizard: idiomas | Conversación francesa, dirección técnica inglesa y frase española literal comprobadas por separado. | +| Wizard: carpetas | Crea y selecciona dos carpetas propias; la carpeta global del servidor se conserva. La selección está virtualizada en el navegador de pruebas. | +| Wizard: cómic | Tres páginas, cuatro viñetas por página y doce imágenes generadas con Flux 2 Klein 9B. JSON y PDF descargados; PDF de tres páginas inspeccionado. | +| Wizard: Series | Creación de serie y episodio guardados. La prueba ampliada exige personajes, lugares, premisa, outline y cuatro planos persistidos. Consultar el último intento del informe para su resultado. | +| Video 3D | Siete vídeos Gandalf conservados fuera de Git y PR #257 independiente. La entrega V3 incluye 33 clips y coche. No es evidencia de generación mediante el Wizard. | + +## Fallos y límites encontrados + +- **Corregido en este cambio:** el endpoint `tools_upscale` ocultaba al módulo + Python del mismo nombre. El worker fallaba antes de adquirir su turno. Se + diferencia el alias del servicio y se comprueba el contrato después de definir + la ruta real. +- **Corregido:** una cabecera cancelada de FIFO podía bloquear los trabajos + siguientes si su worker ya había muerto. Un sucesor retira ese ticket cancelado; + la prueba mantiene un worker ausente y exige que el sucesor adquiera su turno. +- **Corregido:** las utilidades de navegación dejaban sin anchura la fila de + categorías en móvil. Ahora ocupan una fila distinta en pantallas estrechas. +- **Corregido en el test:** esperar las preferencias del modelo antes de fijar + duración y leer de nuevo ante una desconexión breve. La primera música llegó a + completarse aunque el observador había fallado; ese intento sigue marcado como + fallido. Sólo se reintentan GET, nunca la generación. +- **Pendiente de producto:** crear un episodio no genera sus planos. Generar y + aplicar el plan en una única respuesta del Wizard intenta aplicar antes de que + termine el trabajo. El flujo de prueba espera la tarea y aplica su ID en un + turno posterior; no se acredita encadenamiento automático. +- **Pendiente de producto:** Comics save/history resuelven la carpeta global. + La primera prueba del cómic dejó un checkpoint en `default` del backend QA + aislado, sin tocar la app original. El guard ahora excluye estas escrituras. + JSON/PDF se descargan desde el navegador; el guardado/historial en servidor + queda fuera de la certificación de esta suite. +- **Calidad del cómic:** la página inspeccionada presenta cambios de vestuario + y rasgos entre viñetas. Que existan doce imágenes no acredita continuidad de + personaje. Conviene fijar una referencia visual y evaluar cada panel. +- **H3 real:** una prueba anterior fue interrumpida por `systemd-oomd` durante la + decodificación, sin MP4 final. No se presenta como generación aprobada ni se + reanuda automáticamente. El muestreo terminado no garantiza archivo publicado. + +## Lo que esta entrega no certifica + +Cada captura documenta acceso y controles visibles. No certifica cada modelo, +proveedor, parámetro, exportador ni todas las combinaciones de entradas. Las +preferencias globales, gestión de pesos, borrado y recuperación global se +excluyen de los tests en una sesión compartida. Las acciones de Wizard del +manual se distinguen como registradas, parciales o manuales; sólo una traza con +resultado observado acredita su ejecución. + +Para continuar: cubrir por separado voz/SFX, edición de vídeo y máscaras, +generación de GLB, Character Kit, montaje de Director y guardado entre carpetas. +Empezar con `simulate` para la orquestación y ejecutar después una combinación +real acotada por familia, conservando consumo, identidad y archivo decodificado. + +Consulte [la guía de uso](APP_USER_GUIDE.md) y +[el ejecutor nocturno](WIZARD_ACCEPTANCE_TESTING.md). diff --git a/docs/APP_USER_GUIDE.md b/docs/APP_USER_GUIDE.md new file mode 100644 index 00000000..5c9b69fb --- /dev/null +++ b/docs/APP_USER_GUIDE.md @@ -0,0 +1,117 @@ +# HocusPocus: guía de uso y cobertura del Wizard + +Esta guía acompaña la auditoría manual automatizada. Una captura confirma que +una pantalla es accesible; un resultado de generación exige además tarea +completada, archivo publicado y metadatos. El informe conserva esa diferencia. +Las acciones del Wizard indicadas aquí existen en el registro de capacidades; +su presencia no equivale a haber ejecutado cada combinación de modelos. + +## Preparar una sesión + +1. Arranca HocusPocus desde Pinokio y abre la URL que muestra **Start**. +2. Selecciona una carpeta con **Output**. Las generaciones y conversaciones se + guardan en esa carpeta. **Workspaces** contiene colecciones de referencias; + no sustituye a la carpeta de salida. +3. En **Settings**, configura el proveedor del asistente y habilita los modelos + que quieras usar. Un modelo visible puede requerir una descarga antes del + primer trabajo. Comprueba **Activity** antes de lanzar otra generación. +4. Abre **Ask to the Wizard**, explica lo que quieres y distingue entre + «prepáralo sin generar» y «genéralo ahora». Revisa el formulario que rellena. + Una respuesta de texto del asistente no acredita que el archivo exista. + +## Generación directa + +| Pantalla | Cómo usarla | Wizard: alcance y ejemplo | +| --- | --- | --- | +| Image | Elige un modelo de imagen, describe la composición y pulsa Generate. El resultado aparece en Media → Images. | `prepare_image`, `start_generation`: «Prepara una imagen de un taller de magos con Flux 2 Klein 4B y genérala». | +| Video → Frames | Elige modelo y duración; añade fotograma o referencias si el modelo las requiere. Describe el movimiento y genera. | `prepare_video`, `start_generation`: «Prepara un plano de un mago programando, rellena el formulario sin generarlo». | +| Video → Multi-Shot | Divide el vídeo en planos y revisa las instrucciones, tiempos y referencias de cada uno antes de generar. | Preparación general de vídeo; comprueba los controles visibles. No se certifica el ajuste individual de cada control mediante una orden genérica. | +| Video → Extend | Selecciona un vídeo de partida y el punto desde el que continuará; describe la continuación. | Preparación general; la selección exacta de fuente y extensión debe revisarse en el formulario. | +| Video → Blend | Añade las referencias necesarias para el modelo y describe la transición. | Preparación general de vídeo; soporte específico depende del modelo. | +| Audio → Speech | Elige un modelo de voz, escribe el texto literal y añade referencia de voz si corresponde. | `prepare_audio`, `start_generation`: «Prepara una locución que diga exactamente “Hola, mundo” en español». | +| Audio → Music | Selecciona un modelo musical, estilo, letra o Instrumental y duración. Write Song ayuda a redactar; Generate sintetiza el audio. | `prepare_audio`, `start_generation`; para canciones ligadas a una historia, usa Story Lab y sus acciones de canción. | +| Audio → SFX | Describe el sonido y configura su duración con un modelo compatible. | `queue_sfx_pack`: «Prepara una colección de efectos de teclado mágico y chispas». Revisa el plan antes de lanzarlo. | +| Audio → Mixer | Añade pistas y ajusta su mezcla con los controles del panel. | No se identifica una capacidad dedicada para todos los controles del mezclador; operación manual. | +| 3D | Elige un generador 3D, añade una imagen válida y genera. Comprueba el GLB en el visor y en Media → 3D. | `prepare_3d`, `start_generation`: «Prepara un objeto 3D a partir de esta imagen con Hunyuan3D Mini Turbo». | + +El modelo controla qué entradas acepta y cuánta memoria necesita. La duración +del muestreo no incluye necesariamente la decodificación final: espera al +archivo publicado. Si la tarea queda interrumpida tras un cierre, conserva su +identidad y revisa el consumo antes de reanudarla. + +## Edición y herramientas + +| Pantalla | Cómo usarla | Wizard | +| --- | --- | --- | +| Edit → Retake | Carga el vídeo y selecciona el tramo que quieres rehacer. Describe el cambio. | No hay una capacidad dedicada `prepare_edit`; usa los controles de edición. | +| Edit → Edit Anything | Añade el medio de partida y las referencias que pida el modelo; describe el resultado. | Manual para los controles específicos de este modo. | +| Edit → Outpaint | Carga el vídeo y amplía el lienzo para crear área nueva. Generate se bloquea si no hay área que completar. | Manual para fuente, encuadre y área. | +| Edit → Repaint | Selecciona la fuente y describe el aspecto del vídeo final. | Manual para los controles específicos. | +| Edit → Recast | Añade vídeo y referencias del personaje que lo sustituirá. Revisa el modelo y los requisitos. | Manual para los controles específicos. | +| Tools → Upscale | Elige imagen o vídeo desde el ordenador o HocusPocus. Selecciona el método y pulsa Upscale. Lanczos cambia el tamaño sin síntesis de detalle por IA. | No hay una capacidad dedicada de upscale en el registro inspeccionado. | +| Tools → Revoice | Selecciona vídeo, modo de uno o dos hablantes y sus muestras de voz. Pulsa la acción de reemplazo. | Manual. | +| Tools → Remove background | Elige una imagen y, si hace falta, aclara qué objeto conservar. Ejecuta y revisa la transparencia. | `remove_background`: «Quita el fondo de esta imagen y conserva el mago». | + +## Estudios + +| Estudio | Flujo de trabajo | Wizard | +| --- | --- | --- | +| Story Lab | Crea un proyecto, completa premisa, personajes y lugares, genera o edita secciones y guarda. Desde la canción puedes pasar al videoclip. | `create_story`, `update_story`, `generate_story_section`, `configure_story_song`, `generate_story_song`, `stage_story_video`. Ejemplo: «Crea una historia nueva titulada El mago del barrio, completa su premisa y guárdala». | +| Series Lab | Crea una serie y un episodio, prepara el plan de planos, genera los planos y ensambla el episodio. | `create_series_episode`, `generate_series_plan`, `render_series_shots`, `assemble_series_episode`. Pide cada etapa o una producción explícita. | +| Comics | Crea un cómic, define páginas y viñetas, genera y revisa cada panel antes de exportar. | `create_comic`, `generate_comic`, `generate_comic_panel`: «Crea un cómic nuevo de dos páginas sobre un mago programador». | +| Character Creator | Crea un kit de personaje, adjunta referencias consistentes y construye el kit. Usa después sus vistas o rig en otros estudios. | `create_character_kit`, `attach_character_kit_references`, `build_character_kit`. | +| Video 2.5D | Crea una escena de capas, añade imágenes, anima cámara y capas, ajusta el ritmo, guarda y exporta. | `create_3d_scene`, `add_3d_scene_layer`, `apply_3d_rhythm`, `save_3d_scene`, `export_3d_scene`. Estas acciones se refieren a **2.5D**, aunque sus identificadores incluyan `3d`. | +| Video 3D | Monta objetos GLB en un mundo, ajusta escala y cámara, elige las animaciones disponibles y sus recorridos, previsualiza, guarda la escena y exporta. | En el registro inspeccionado no hay capacidades dedicadas para montar GLB y ajustar las cámaras de este editor. Hazlo manualmente. | +| Replace character | Selecciona el vídeo y un fotograma editado que muestre la sustitución. Revisa los ajustes y genera. | Manual. | +| Animate | Abre un personaje compatible, revisa su rig y selecciona la animación con los controles del estudio. | `open_character_kit_rig` abre el rig del kit; la edición completa de animación requiere controles manuales. | + +En el editor de la PR #257, selecciona un objeto y trabaja dentro del visor: +**G** mueve, **R** rota alrededor del eje Y y **S** cambia su escala uniforme. +La ayuda traducida aparece durante la interacción y desaparece al soltar o +salir. Estos atajos no interceptan lo que escribes en un campo de texto. + +Las plantillas cinematográficas de la PR #257 amplían Video 3D con primeros +planos, persecuciones y planos de coche. La animación de caminar o correr debe +acompañarse de un recorrido para desplazarse por el escenario. Un GLB sin rig +ni piezas separadas puede trasladarse como objeto completo, pero eso no crea +ruedas articuladas ni zonas de pintura independientes. + +En Series Lab, crear el episodio prepara sus datos iniciales. Para disponer de +planos, pide después «genera el plan completo de este episodio, sin renderizar». +Espera a que la tarea termine y pide «aplica la propuesta completada con este +jobId». El flujo inspeccionado no espera automáticamente entre generar y +aplicar cuando el Wizard propone ambas acciones seguidas en una sola respuesta. + +## Producción + +| Pantalla | Cómo usarla | Wizard | +| --- | --- | --- | +| Director | Envía una historia o canción concreta, prepara el plan, revisa escenas y lanza la producción. Sigue la tarea hasta el MP4 final. | `stage_story_video`, `stage_story_music_video`, `start_director_production`. Identifica título y canción para evitar usar otra selección. | +| Video Editor | Crea un proyecto, añade vídeos, recorta clips, añade audio y exporta el montaje. | `create_video_editor_project`, `add_video_editor_clips`, `trim_video_editor_clip`, `add_video_editor_audio`, `export_video_editor`. Ejemplo: «Monta estos dos vídeos en este orden, añade esta canción y exporta». | +| Productions | Consulta las producciones existentes y abre sus resultados o tareas. | Consulta y navegación parciales; la generación se inicia en el estudio o en Director. | + +## Biblioteca, organización y seguimiento + +| Pantalla o filtro | Uso | Wizard | +| --- | --- | --- | +| Media → Projects | Abre proyectos guardados y retoma su edición. | Navegación y selección dependen del tipo de proyecto; no asumas que un filtro equivale a una acción de producción. | +| Media → Assets | Busca referencias reutilizables y elige la identidad exacta del asset. | Puede trabajar con assets identificados por las capacidades correspondientes; revisa la selección. | +| All, Images, Videos, Audio, 3D | Filtra los archivos publicados por tipo. | Navegación parcial; los filtros pueden ajustarse manualmente. | +| Videoclips, Trailers, Episodes | Encuentra resultados clasificados por tipo de producción. | Pide preparar o producir desde Story/Director/Series; el filtro sirve para consultar resultados. | +| Scenes, Style sheet | Localiza escenas y material de estilo guardado. | 2.5D dispone de guardar/seleccionar escena. El resto depende del editor correspondiente. | +| Edits, Multi-clip, Favorites | Filtra por edición, montaje o favorito. | Ajuste manual de filtros y favoritos cuando no haya una capacidad específica. | +| Workspaces | Crea colecciones de referencias y notas conservando los IDs de sus assets. | `create_workspace_collection`, `update_workspace_collection`. No es lo mismo que cambiar Output. | +| Activity | Consulta cola, recursos y errores; abre detalles de una tarea antes de cancelarla o reintentarla. | `inspect_queue`, `cancel_task`, `retry_task`, `resume_task`. Ejemplo: «Muestra las tareas de esta carpeta y explica cuál sigue activa». | +| Settings | Configura idioma, apariencia, modelos, proveedores y almacenamiento. | Navegación y descarga de modelos (`download_model`) parciales; credenciales y preferencias se revisan manualmente. | + +## Cómo repetir y leer la auditoría + +Sigue [WIZARD_ACCEPTANCE_TESTING.md](WIZARD_ACCEPTANCE_TESTING.md). Cada intento +guarda su propio informe, capturas, traza y resultados; no sobreescribe intentos +anteriores. `app-tour` recorre las pantallas. `app-generate` ejecuta casos de +generación y herramientas y requiere el perfil `real` con `--confirm-real`. + +Comprueba por separado: navegación, generación, persistencia, exportación y +ejecución desde el Wizard. Un caso fallido sigue siendo evidencia útil: no se +elimina ni se transforma en éxito al repetirlo. La cobertura de una familia de +funciones tampoco certifica todos sus modelos, proveedores o parámetros. diff --git a/docs/WIZARD_ACCEPTANCE_TESTING.md b/docs/WIZARD_ACCEPTANCE_TESTING.md index 0c4ab4a9..32aa0f94 100644 --- a/docs/WIZARD_ACCEPTANCE_TESTING.md +++ b/docs/WIZARD_ACCEPTANCE_TESTING.md @@ -88,21 +88,46 @@ port, so `--base-url` is required unless `HOCUSPOCUS_BASE_URL` already contains the exact URL shown by Pinokio. Available scenarios are `smoke`, `full`, `studio`, `language`, `music-video`, -`music-video-new`, `comic`, `series`, `failure`, `cancel` and `workspace`. `language` +`music-video-new`, `comic`, `series`, `failure`, `cancel`, `workspace`, +`wizard-media`, `app-tour`, `app-generate` and `app`. `language` verifies a live mixed-language turn (conversation, content, speech, exact quote and technical provider prompt). `music-video-new` is the one-turn regression for a newly authored song and videoclip: it proves that the Wizard creates a fresh Story project, fills and generates its vocal ACE-Step song, carries the exact cue identity into Director and never falls back to an unrelated selected song. -`full` runs the principal successful flows serially. +`full` runs the principal Wizard flows serially. `app-tour` captures every +primary destination, direct-generation submode, tool panel, Settings, Activity +and mobile navigation. `app-generate` uses visible native controls to generate +an image and instrumental music and to upscale the generated image; it checks +canonical completion, downloaded media and metadata. `app` combines these +native UI cases with the tour. `app-generate` and `app` require `real`; the +image case needs an enabled Flux 2 Klein 9B, and the music case needs the +configured ACE-Step model installed. Music waits for model defaults before +setting 30 seconds and checks the decoded duration. Upscale uses Lanczos ×2 +and checks the decoded dimensions. These are family-level cases, not certification +of every model and parameter combination. `wizard-media` is the current live +Wizard acceptance pass for two independent native generations: a Flux 2 Klein 9B +image followed by a 20-second ACE-Step instrumental track. Both submissions start +in the visible Ask to the Wizard panel, resolve through the canonical queue, and +retain the literal prompts, Wizard command trace, task IDs, output bytes and browser +decode evidence. It requires the corresponding local models to be installed and +enabled and must be run with `--profile real --confirm-real`. Use `--headed` to watch the Wizard navigate and fill the application. Use -`--resume` to ask Playwright to run only failures from its previous run. +`--resume --output-dir ` to ask Playwright to rerun failures +from that root's previous completed attempt. It creates a new evidence folder; +it does not resume a backend generation or overwrite the earlier report. +Resume rejects a corrupt, empty or successful `.last-run.json` before launching +Playwright: without a valid list of failed IDs, `--last-failed` could otherwise +select the complete scenario again. Real GPU acceptance is intentionally hard to trigger: ```bash python3 scripts/run_wizard_acceptance.py \ - --profile real --scenario studio --confirm-real + --base-url http://127.0.0.1: \ + --profile real --scenario app --confirm-real \ + --output-dir outputs/acceptance-my-run \ + --workspace-prefix e2e_my_run ``` The runner refuses a mismatch between the requested profile and the backend's @@ -128,9 +153,47 @@ to validate the complete orchestration cheaply while still consulting the real L ## Evidence and assertions -The HTML report is written to `ui/playwright-report/wizard-live`; raw results, -traces, screenshots and retained failure videos go to -`ui/test-results/wizard-live`. Both paths are ignored by Git. +The runner defaults to a unique `outputs/acceptance-` root. +`--output-dir` selects a reusable root. Each attempt gets its own +`attempt-/report`, `raw` and `results.json`. `run.json` records +the invocation and exit status, and `index.html` links the evidence. An abrupt +OS kill can leave an attempt marked `running`; inspect its process and backend +task before starting another inference. Never treat that file alone as proof +of liveness or completion. Run one invocation per evidence root at a time. + +Use `--browser-executable /path/to/chromium` when the normal Playwright browser +is unavailable. This selects an owned test browser, not the user's browser. +Direct Playwright calls still accept `HOCUSPOCUS_E2E_ARTIFACT_DIR` and require +the actual `HOCUSPOCUS_BASE_URL`. They do not get the runner's manifest. + +To create one index over several scenario roots: + +```bash +python3 scripts/acceptance_report.py outputs/my-audit +``` + +Real-mode cases create fresh `e2e_` folders. The browser harness virtualizes +active-folder selection and shared model/profile preferences so it does not +switch the user's global folder or overwrite their preferences. It restricts +the recovery listing to the current test folder and forbids global recovery +resume/discard. The interrupted queue and test outputs are preserved. This +means **server persistence of those global preferences and recovery actions +is not tested**. Generation, LLM calls, project saves, tasks and media remain +live, except legacy Comics save/history routes: those resolve the server's +global active folder even when a browser has another selected folder. The +harness blocks those writes. The comic case validates all 12 panel images +and browser JSON/PDF downloads; it does **not** certify server save/history. +Story and Series library writes remain live within the selected test folder. +Task controls, including legacy cancel/stop routes, must resolve to canonical +tasks in that folder. JSON and query destinations are checked independently; +native submissions require an explicit JSON workspace. Deletion is excluded. +Do not remove the isolation guard to get a failing case to pass. + +See [APP_USER_GUIDE.md](APP_USER_GUIDE.md) for usage and the Wizard capability +matrix. The tour's `features.json` records screenshot coverage separately from +registry support. The native media cases also retain sampled RAM/VRAM data; +the preflight refuses to submit while host RAM usage is already at 80%. +This is a preflight check, not a prediction or prevention of model peak memory. Every live scenario records: @@ -152,14 +215,23 @@ canonical task. 1. Run the ordinary Python/UI checks without models. 2. Boot `plan` and run `smoke` to catch LLM/schema/form regressions cheaply. -3. Boot `simulate` and run `full` for chained workflows. +3. Run `app-tour` to capture the visible destinations, then boot `simulate` + and run `full` for chained workflows. 4. Boot `simulate` with one injected failure and run `failure`. 5. Boot `simulate` with `HOCUSPOCUS_SIMULATION_STEP_DELAY=1` and run `cancel` to exercise the visible Activity cancellation path. -6. Run `workspace` to prove that the Wizard refreshes its exact UI/server - context after a switch (the temporary secondary workspace is deleted). -7. Only for release candidates, boot `real` and run a small explicitly chosen - GPU scenario. +6. Run `workspace` to prove that the Wizard refreshes its folder context after + a browser-local switch. Both test folders are preserved for review. +7. For real acceptance, boot `real` and run `app-generate --confirm-real`. + Keep local inference serial. Add `comic`, `series` or `music-video-new` + explicitly according to the available models and provider configuration. + +For a single real audit index, use separate output roots under one parent +(`outputs/my-audit/tour`, `outputs/my-audit/native`, etc.), then run +`python3 scripts/acceptance_report.py outputs/my-audit`. This preserves every +scenario's resume state. Copy `docs/APP_USER_GUIDE.md` into that parent if you +want the offline index to link the manual. Only read requests retry brief +socket interruptions; a POST is never automatically replayed by the harness. The simulated artifacts are intentionally tiny, deterministic and structurally valid so audio analysis, FFmpeg assembly, gallery discovery and Director diff --git a/scripts/acceptance_report.py b/scripts/acceptance_report.py new file mode 100644 index 00000000..6fb55f90 --- /dev/null +++ b/scripts/acceptance_report.py @@ -0,0 +1,113 @@ +"""Build a portable, offline index from preserved live acceptance evidence.""" +from __future__ import annotations + +import argparse +from html import escape +import json +from pathlib import Path +from urllib.parse import quote + + +def read_json(path: Path, errors: list[str]) -> dict | list: + try: + return json.loads(path.read_text(encoding='utf-8')) + except (OSError, ValueError) as exc: + errors.append(f'{path.name}: {exc}') + return {} + + +def link(root: Path, target: Path) -> str: + return quote(target.relative_to(root).as_posix(), safe='/') + + +def feature_cards(root: Path, errors: list[str]) -> str: + latest: dict[str, tuple[dict, Path]] = {} + for path in sorted(root.rglob('features.json'), key=lambda item: item.stat().st_mtime): + for record in read_json(path, errors): + latest[record['id']] = (record, path.parent) + cards = [] + for record, directory in latest.values(): + title = escape(' → '.join(record['route'])) + wizard = record['wizard'] + screenshot = link(root, directory / record['screenshot']) + status = escape(record['status']) + cards.append(f'''

{title}

+

Acceso a pantalla: {status}. Generación: ver casos ejecutados.

+{title} +

Wizard: {escape(wizard['support'])} · {escape(', '.join(wizard['actions']))}

+

{escape(wizard['example'])}

{escape(wizard['note'])} +
Detalle del intento
{escape(record.get('error') or 'Sin error de navegación registrado.')}
''') + return '\n'.join(cards) + + +def attempt_rows(root: Path, errors: list[str]) -> str: + attempts: dict[Path, dict] = {} + for path in sorted(root.rglob('run.json')): + data = read_json(path, errors) + for record in data.get('attempts', []): + # Match within this portable evidence tree, not the old machine path. + attempts[path.parent / Path(record['path']).name] = record + for path in sorted(root.rglob('results.json')): + attempts.setdefault(path.parent, {}) + rows = [] + for directory, record in sorted(attempts.items()): + results = directory / 'results.json' + data = read_json(results, errors) if results.exists() else {} + stats = data.get('stats', {}) + status = escape(record.get('status', 'sin manifest')) + if stats: + status += f" · {stats.get('expected', 0)} pasan · {stats.get('unexpected', 0)} fallan · {stats.get('skipped', 0)} omitidos" + else: + status += ' · sin resultado Playwright; no se acredita éxito' + report = directory / 'report' / 'index.html' + target = report if report.exists() else results if results.exists() else directory.parent / 'run.json' + rows.append(f'
  • {escape(str(directory.relative_to(root)))} — {status}
  • ') + return '\n'.join(rows) + + +def generation_rows(root: Path, errors: list[str]) -> str: + rows = [] + for path in sorted(root.rglob('*-result.json')): + data = read_json(path, errors) + if not isinstance(data, dict) or 'output' not in data or 'task' not in data: + continue + output = data['output'] + local = path.parent / output['name'] + target = local if local.exists() else path + rows.append(f'''
  • {escape(output['name'])} +· {escape(data['task']['status'])} · {data.get('bytes', 0)} bytes +· Identidad, tarea y metadatos
  • ''') + return '\n'.join(rows) or '
  • Aún no hay resultados de generación verificados en este índice.
  • ' + + +def build_report(root: Path) -> Path: + root = root.resolve() + errors: list[str] = [] + html = ''' +HocusPocus · Auditoría de uso

    HocusPocus · Auditoría de uso

    +

    Capturas, resultados y acceso desde Ask to the Wizard. El inventario muestra la última captura de cada función; conserva los intentos anteriores en sus informes.

    +

    Alcance: una pantalla accesible no acredita generación ni exportación. «registered» significa capacidad encontrada en el registro, «partial» soporte parcial y «manual» controles manuales. La ejecución real del Wizard requiere su traza y el resultado del caso.

    +GUIDE +

    Casos e intentos

      ATTEMPTS

    Resultados verificados

      RESULTS
    +WARNINGS +

    Inventario de pantallas

    CARDS
    +''' + html = html.replace('ATTEMPTS', attempt_rows(root, errors)).replace('RESULTS', generation_rows(root, errors)).replace('CARDS', feature_cards(root, errors)) + guide = root / 'APP_USER_GUIDE.md' + html = html.replace('GUIDE', f'

    Guía de uso y acciones del Wizard

    ' if guide.exists() else '') + warnings = '

    Evidencia incompleta

      ' + ''.join(f'
    • {escape(error)}
    • ' for error in errors) + '
    ' if errors else '' + html = html.replace('WARNINGS', warnings) + target = root / 'index.html' + target.write_text(html, encoding='utf-8') + return target + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('root', type=Path, help='Existing evidence directory; no files are removed') + args = parser.parse_args() + if not args.root.is_dir(): + parser.error('root must be an existing evidence directory') + print(build_report(args.root)) diff --git a/scripts/run_wizard_acceptance.py b/scripts/run_wizard_acceptance.py index 47a736aa..31311658 100644 --- a/scripts/run_wizard_acceptance.py +++ b/scripts/run_wizard_acceptance.py @@ -4,14 +4,18 @@ from __future__ import annotations import argparse +from datetime import datetime, timezone import json import os from pathlib import Path +import shutil import subprocess import sys from urllib.error import URLError from urllib.request import urlopen +from acceptance_report import build_report + ROOT = Path(__file__).resolve().parents[1] @@ -25,10 +29,13 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument('--base-url', default=os.environ.get('HOCUSPOCUS_BASE_URL')) parser.add_argument('--profile', choices=('plan', 'simulate', 'real'), default='simulate') - parser.add_argument('--scenario', choices=('smoke', 'full', 'studio', 'language', 'music-video', 'music-video-new', 'comic', 'series', 'failure', 'cancel', 'workspace'), default='smoke') + parser.add_argument('--scenario', choices=('smoke', 'full', 'studio', 'language', 'music-video', 'music-video-new', 'comic', 'series', 'failure', 'cancel', 'workspace', 'wizard-media', 'app-tour', 'app-generate', 'app'), default='smoke') parser.add_argument('--headed', action='store_true') parser.add_argument('--resume', action='store_true', help='Run only tests that failed in the previous Playwright invocation') parser.add_argument('--confirm-real', action='store_true') + parser.add_argument('--output-dir', type=Path, help='Evidence root; each attempt gets its own directory') + parser.add_argument('--workspace-prefix', help='New real-mode output folders start with this e2e_ prefix') + parser.add_argument('--browser-executable', help='Optional Chromium executable, e.g. /snap/bin/chromium') args = parser.parse_args() if not args.base_url: @@ -54,6 +61,62 @@ def main() -> int: if args.profile == 'real' and not args.confirm_real: print('Real generation requires --confirm-real.', file=sys.stderr) return 2 + if args.scenario in ('app-generate', 'app') and args.profile != 'real': + print('Native media assertions require --profile real --confirm-real. Use app-tour for other profiles.', file=sys.stderr) + return 2 + + stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') + output = (args.output_dir or ROOT / 'outputs' / f'acceptance-{stamp}').resolve() + manifest_path = output / 'run.json' + if args.resume and not manifest_path.exists(): + print('--resume needs --output-dir pointing to an existing run.json.', file=sys.stderr) + return 2 + try: + manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {'attempts': []} + if not isinstance(manifest, dict) or not isinstance(manifest.get('attempts'), list): + raise ValueError('expected an attempts list') + except (OSError, ValueError) as exc: + print(f'Cannot read existing manifest; preserving it: {exc}', file=sys.stderr) + return 2 + prefix = args.workspace_prefix or manifest.get('workspace_prefix') or f'e2e_acceptance_{stamp}' + if not prefix.startswith(('e2e_', 'e2e-')) or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-' for c in prefix): + print('--workspace-prefix must be an e2e_ name containing only letters, digits, _ or -.', file=sys.stderr) + return 2 + previous = None + if args.resume: + if not manifest['attempts'] or manifest['attempts'][-1]['status'] == 'running': + print('Previous attempt is not finalized. Inspect its process and backend task before resuming.', file=sys.stderr) + return 2 + if manifest['attempts'][-1]['scenario'] != args.scenario or manifest.get('profile') != args.profile: + print('--resume must use the previous scenario and profile.', file=sys.stderr) + return 2 + previous = Path(manifest['attempts'][-1]['path']) / 'raw' / '.last-run.json' + if not previous.exists(): + print('Previous attempt has no Playwright .last-run.json; cannot resume.', file=sys.stderr) + return 2 + try: + last_run = json.loads(previous.read_text()) + failed_ids = last_run.get('failedTests') if isinstance(last_run, dict) else None + if ( + not isinstance(last_run, dict) + or last_run.get('status') not in ('failed', 'interrupted', 'timedout') + or not isinstance(failed_ids, list) + or not failed_ids + or not all(isinstance(item, str) and item.strip() for item in failed_ids) + ): + raise ValueError('expected a failed/interrupted run with non-empty failed test IDs') + except (OSError, ValueError) as exc: + print(f'Cannot resume invalid or empty Playwright failure state; no tests launched: {exc}', file=sys.stderr) + return 2 + attempt = output / f'attempt-{stamp}' + attempt.mkdir(parents=True, exist_ok=False) + if previous: + (attempt / 'raw').mkdir() + shutil.copy2(previous, attempt / 'raw' / '.last-run.json') + manifest.update({'base_url': base_url, 'profile': args.profile, 'workspace_prefix': prefix}) + record = {'path': str(attempt), 'scenario': args.scenario, 'base_url': base_url, 'profile': args.profile, 'workspace_prefix': prefix, 'started_at': stamp, 'status': 'running'} + manifest['attempts'].append(record) + manifest_path.write_text(json.dumps(manifest, indent=2) + '\n') environment = os.environ.copy() environment.update({ @@ -61,13 +124,32 @@ def main() -> int: 'HOCUSPOCUS_E2E_PROFILE': args.profile, 'HOCUSPOCUS_E2E_SCENARIO': args.scenario, 'HOCUSPOCUS_E2E_CONFIRM_REAL': 'YES' if args.confirm_real else '', + 'HOCUSPOCUS_E2E_WORKSPACE': prefix, + 'HOCUSPOCUS_E2E_ARTIFACT_DIR': str(attempt), }) + if args.browser_executable: + environment['HOCUSPOCUS_E2E_CHROMIUM_EXECUTABLE'] = args.browser_executable command = ['npm', 'run', 'test:e2e:wizard', '--'] if args.headed: command.append('--headed') if args.resume: command.append('--last-failed') - return subprocess.run(command, cwd=ROOT / 'ui', env=environment, check=False).returncode + print(f'Evidence: {attempt}', flush=True) + try: + result = subprocess.run(command, cwd=ROOT / 'ui', env=environment, check=False).returncode + except KeyboardInterrupt: + result = 130 + except OSError as exc: + print(f'Cannot launch browser suite: {exc}', file=sys.stderr) + result = 2 + record.update({'status': 'passed' if result == 0 else 'interrupted' if result in (130, -2, -15) else 'failed', 'exit_code': result, 'finished_at': datetime.now(timezone.utc).isoformat()}) + latest = json.loads(manifest_path.read_text()) + by_path = {item['path']: item for item in latest['attempts']} + by_path[record['path']] = record + latest['attempts'] = sorted(by_path.values(), key=lambda item: item['started_at']) + manifest_path.write_text(json.dumps(latest, indent=2) + '\n') + print(f'Review: {build_report(output)}') + return result if __name__ == '__main__': diff --git a/tests/fixtures/architecture_wire_inventory.json b/tests/fixtures/architecture_wire_inventory.json index 85dcd568..8a342d7f 100644 --- a/tests/fixtures/architecture_wire_inventory.json +++ b/tests/fixtures/architecture_wire_inventory.json @@ -370,8 +370,8 @@ { "file": "tests/test_tools_upscale_contract.py", "target": "app/_launch_runtime.py", - "classification": "architecture_rule", - "reason": "Parses launch wiring intentionally; preserve the rule while changing its source location." + "classification": "symbol_importable", + "reason": "Extracts selected launch symbols with AST/exec; migrate to direct imports when that domain moves." }, { "file": "tests/test_video_editor_scheduler_jobs.py", diff --git a/tests/test_acceptance_runner.py b/tests/test_acceptance_runner.py new file mode 100644 index 00000000..299dd5e1 --- /dev/null +++ b/tests/test_acceptance_runner.py @@ -0,0 +1,110 @@ +"""The live runner preserves failures and never turns a resume into a new full run.""" +import importlib.util +import json +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] / 'scripts' + + +@pytest.fixture +def runner(monkeypatch): + monkeypatch.syspath_prepend(str(SCRIPTS)) + spec = importlib.util.spec_from_file_location('acceptance_runner_test', SCRIPTS / 'run_wizard_acceptance.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module, 'read_json', lambda _: {'execution_mode': 'real'}) + return module + + +def invoke(monkeypatch, runner, root, *extra): + monkeypatch.setattr(sys, 'argv', ['runner', '--base-url', 'http://127.0.0.1:42004', + '--profile', 'real', '--confirm-real', '--scenario', 'app-generate', + '--output-dir', str(root), *extra]) + return runner.main() + + +def test_resume_keeps_original_evidence_and_copies_only_failed_ids(monkeypatch, runner, tmp_path): + calls = [] + + def execute(command, *, env, **_): + attempt = Path(env['HOCUSPOCUS_E2E_ARTIFACT_DIR']) + last_run = attempt / 'raw' / '.last-run.json' + if calls: + assert '--last-failed' in command + assert json.loads(last_run.read_text())['failedTests'] == ['music-id'] + last_run.parent.mkdir(exist_ok=True) + last_run.write_text(json.dumps({'status': 'failed', 'failedTests': ['music-id']})) + (attempt / 'results.json').write_text(json.dumps({'stats': {'expected': int(bool(calls)), 'unexpected': int(not calls)}})) + calls.append(attempt) + return SimpleNamespace(returncode=1 if len(calls) == 1 else 0) + + monkeypatch.setattr(runner.subprocess, 'run', execute) + assert invoke(monkeypatch, runner, tmp_path) == 1 + original = (calls[0] / 'results.json').read_bytes() + assert invoke(monkeypatch, runner, tmp_path, '--resume') == 0 + manifest = json.loads((tmp_path / 'run.json').read_text()) + assert [attempt['status'] for attempt in manifest['attempts']] == ['failed', 'passed'] + assert calls[0] != calls[1] + assert (calls[0] / 'results.json').read_bytes() == original + assert '1 fallan' in (tmp_path / 'index.html').read_text() + + +@pytest.mark.parametrize('failure,code,status', [(KeyboardInterrupt(), 130, 'interrupted'), (OSError('npm absent'), 2, 'failed')]) +def test_launch_interruptions_are_finalized(monkeypatch, runner, tmp_path, failure, code, status): + def execute(*_, **__): + raise failure + monkeypatch.setattr(runner.subprocess, 'run', execute) + assert invoke(monkeypatch, runner, tmp_path) == code + record = json.loads((tmp_path / 'run.json').read_text())['attempts'][0] + assert record['status'] == status + assert record['finished_at'] + assert 'sin resultado Playwright' in (tmp_path / 'index.html').read_text() + + +@pytest.mark.parametrize('manifest', ['{incomplete', json.dumps({'attempts': [{'status': 'running'}]})]) +def test_invalid_or_running_attempt_is_preserved(monkeypatch, runner, tmp_path, manifest): + (tmp_path / 'run.json').write_text(manifest) + monkeypatch.setattr(runner.subprocess, 'run', lambda *_a, **_k: pytest.fail('Must not submit')) + assert invoke(monkeypatch, runner, tmp_path, '--resume') == 2 + assert (tmp_path / 'run.json').read_text() == manifest + + +def test_mode_mismatch_does_not_launch(monkeypatch, runner, tmp_path): + monkeypatch.setattr(runner, 'read_json', lambda _: {'execution_mode': 'simulate'}) + monkeypatch.setattr(runner.subprocess, 'run', lambda *_a, **_k: pytest.fail('Must not submit')) + assert invoke(monkeypatch, runner, tmp_path) == 2 + assert not (tmp_path / 'run.json').exists() + + +@pytest.mark.parametrize('last_run', ['{truncated', 'null', '[]', '{}', + '{"status":"failed","failedTests":[]}', '{"status":"passed","failedTests":["old-id"]}', + '{"status":"failed","failedTests":[""]}', '{"status":"failed","failedTests":[3]}']) +def test_resume_rejects_missing_or_corrupt_failure_filter_without_running_all_tests(monkeypatch, runner, tmp_path, last_run): + previous = tmp_path / 'attempt-original' + (previous / 'raw').mkdir(parents=True) + state = previous / 'raw' / '.last-run.json' + state.write_text(last_run) + manifest = {'profile': 'real', 'attempts': [{'path': str(previous), 'scenario': 'app-generate', 'status': 'failed'}]} + (tmp_path / 'run.json').write_text(json.dumps(manifest)) + monkeypatch.setattr(runner.subprocess, 'run', lambda *_a, **_k: pytest.fail('Corrupt --last-failed would run every test')) + assert invoke(monkeypatch, runner, tmp_path, '--resume') == 2 + assert list(tmp_path.glob('attempt-*')) == [previous] + assert state.read_text() == last_run + assert json.loads((tmp_path / 'run.json').read_text()) == manifest + + +def test_portable_report_escapes_content_and_shows_incomplete_evidence(monkeypatch, runner, tmp_path): + attempt = tmp_path / 'attempt-one' + attempt.mkdir() + (tmp_path / 'run.json').write_text(json.dumps({'attempts': [{'path': '/old/computer/attempt-one', 'status': 'interrupted'}]})) + (attempt / 'results.json').write_text('{truncated') + (attempt / 'features.json').write_text(json.dumps([{'id': 'image', 'route': [''], 'status': 'failed', 'screenshot': 'one image.png', 'wizard': {'support': 'manual', 'actions': [], 'example': '', 'note': 'a & b'}}])) + html = runner.build_report(tmp_path).read_text() + assert '' not in html + assert '<script>' in html and 'one%20image.png' in html + assert 'interrupted' in html and 'Evidencia incompleta' in html + assert '/old/computer' not in html diff --git a/tests/test_job_lifecycle.py b/tests/test_job_lifecycle.py index 13b85fed..4b18c850 100644 --- a/tests/test_job_lifecycle.py +++ b/tests/test_job_lifecycle.py @@ -490,6 +490,35 @@ def wait_for_slot(): self.assertEqual(result, [False]) generation_lock.release() + def test_cancelled_orphan_waiter_cannot_block_the_next_job(self): + generation_lock = threading.Lock() + orphan = {"id": "worker-failed-before-acquire", "status": "queued"} + successor = {"id": "next-job", "status": "queued"} + register_generation_job(generation_lock, orphan) + register_generation_job(generation_lock, successor) + request_cancel(orphan) + result = [] + + def next_worker(): + acquired = acquire_generation_slot(generation_lock, successor, poll_interval=0.01) + result.append(acquired) + if acquired: + generation_lock.release() + + worker = threading.Thread(target=next_worker) + worker.start() + try: + worker.join(timeout=1) + self.assertFalse(worker.is_alive(), "a cancelled waiter with no worker must not retain its FIFO position") + self.assertEqual(result, [True]) + self.assertIsNone(generation_queue_position(generation_lock, orphan)) + self.assertFalse(generation_lock.locked()) + finally: + request_cancel(successor) + worker.join(timeout=1) + # Also drain an orphan when demonstrating this regression on old code. + acquire_generation_slot(generation_lock, orphan, poll_interval=0.01) + def test_generation_slot_is_fifo_by_registration_not_thread_schedule(self): generation_lock = threading.Lock() generation_lock.acquire() diff --git a/tests/test_tools_upscale_contract.py b/tests/test_tools_upscale_contract.py index 55dbaf6f..ad0fc50e 100644 --- a/tests/test_tools_upscale_contract.py +++ b/tests/test_tools_upscale_contract.py @@ -28,6 +28,39 @@ ROOT = Path(__file__).resolve().parents[1] +def test_runtime_upscale_service_survives_registration_of_same_named_route(monkeypatch): + """Exercise the production facades after the endpoint has been defined. + + Loading the complete bootstrap would initialize model services. Compiling + these definitions together retains Python's real global name binding, + which previously replaced the service module with the endpoint function. + """ + from services import tools_upscale as service + + tree = ast.parse((ROOT / "app" / "_launch_runtime.py").read_text(encoding="utf-8")) + imports = [node for node in tree.body if isinstance(node, ast.ImportFrom) + and node.module == "services" and any(alias.name == "tools_upscale" for alias in node.names)] + definitions = [node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name in {"_upscale_tool_image", "_run_tool_upscale", "tools_upscale"}] + for node in definitions: + node.decorator_list = [] + namespace = {"Request": object, "wgp": object(), "_jobs": {}, "_active_gen_states": {}} + for key in ("_coordinated_generation_slot", "try_start", "register_abort_state", "unregister_abort_state", + "is_cancel_requested", "update_job", "finish_job", "acknowledge_cancel", "record_job_outputs", + "_chunked_flashvsr_upscale", "_resolve_tool_clip_path", "_write_tool_sidecar"): + namespace[key] = object() + calls = [] + monkeypatch.setattr(service, "upscale_image", lambda *args, **kwargs: calls.append((args, kwargs)) or (24, 16)) + monkeypatch.setattr(service, "run_tool_upscale", lambda *args, **kwargs: calls.append((args, kwargs)) or "delegated") + exec(compile(ast.Module(body=imports + definitions, type_ignores=[]), "runtime-upscale-boundary", "exec"), namespace) + assert callable(namespace["tools_upscale"]) + assert namespace["_upscale_tool_image"]("input.png", "output.png", "lanczos2") == (24, 16) + assert namespace["_run_tool_upscale"]("test-job") == "delegated" + assert calls[0][0] == ("input.png", "output.png", "lanczos2") + assert calls[1][0] == ("test-job",) + assert calls[1][1]["runtime"]["jobs"] is namespace["_jobs"] + + def _workspace_functions(workspace: Path, uploads: Path): return { "workspace_dir": lambda _workspace: str(workspace), diff --git a/ui/e2e/helpers/appAudit.ts b/ui/e2e/helpers/appAudit.ts new file mode 100644 index 00000000..74a9b144 --- /dev/null +++ b/ui/e2e/helpers/appAudit.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs/promises' +import { expect, type Page, type TestInfo } from '@playwright/test' + +export type FeatureEvidence = { + id: string; title: string; route: string[]; screenshot: string; status: 'passed' | 'failed'; error?: string + wizard: { support: 'registered' | 'partial' | 'manual'; actions: string[]; example: string; note: string } +} + +const wizardRoutes: Record = { + 'Direct generation/Image': { actions: ['prepare_image', 'start_generation'], example: 'Prepara una imagen de un taller de magos con Flux 2 Klein 9B y genérala.' }, + 'Direct generation/Video': { actions: ['prepare_video', 'start_generation'], example: 'Prepara un plano de 5 segundos de un mago programando y genéralo.' }, + 'Direct generation/Audio': { actions: ['prepare_audio', 'start_generation', 'queue_sfx_pack'], example: 'Crea y genera una canción instrumental de prueba con ACE-Step.' }, + 'Direct generation/3D': { actions: ['prepare_3d', 'start_generation'], example: 'Prepara un objeto 3D a partir de esta imagen con Hunyuan3D Mini Turbo.' }, + 'Direct generation/Edit': { actions: ['open_tab'], example: 'Abre Studio; el modo y los controles de edición se ajustan manualmente.', support: 'partial' }, + 'Direct generation/Tools': { actions: ['remove_background'], example: 'Quita el fondo de la imagen indicada. Upscale y Revoice requieren controles manuales.', support: 'partial' }, + 'Direct generation/Tools/Upscale': { actions: [], example: 'Selecciona el medio y el método en Tools → Upscale.', support: 'manual' }, + 'Direct generation/Tools/Revoice': { actions: [], example: 'Selecciona el vídeo y las muestras de voz en Tools → Revoice.', support: 'manual' }, + 'Direct generation/Tools/Remove background': { actions: ['remove_background'], example: 'Quita el fondo de esta imagen y conserva el mago.' }, + 'Direct generation/Audio/Music': { actions: ['prepare_audio', 'start_generation'], example: 'Prepara una canción instrumental con ACE-Step y genérala.', support: 'partial' }, + 'Direct generation/Audio/Speech': { actions: ['prepare_audio', 'start_generation'], example: 'Prepara una locución que diga exactamente «Hola, mundo» en español.', support: 'partial' }, + 'Direct generation/Audio/SFX': { actions: ['queue_sfx_pack'], example: 'Prepara una colección de efectos de teclado mágico y chispas.' }, + 'Direct generation/Audio/Mixer': { actions: [], example: 'Añade pistas y ajusta la mezcla manualmente en Audio → Mixer.', support: 'manual' }, + 'Studios/Story Lab': { actions: ['create_story', 'update_story', 'generate_story_section', 'configure_story_song', 'generate_story_song', 'stage_story_video'], example: 'Crea una historia nueva de un mago programador, completa la premisa y guárdala.' }, + 'Studios/Series Lab': { actions: ['create_series_episode', 'generate_series_plan', 'render_series_shots', 'assemble_series_episode'], example: 'Crea una serie de comedia tecnológica y prepara un episodio de cuatro planos.' }, + 'Studios/Comics': { actions: ['create_comic', 'generate_comic', 'generate_comic_panel'], example: 'Crea un cómic nuevo de dos páginas y genera sus viñetas con MiniMax.' }, + 'Studios/Character Creator': { actions: ['create_character_kit', 'attach_character_kit_references', 'build_character_kit'], example: 'Crea un Character Kit de un mago con abrigo azul usando la referencia adjunta.' }, + 'Studios/Video 2.5D': { actions: ['create_3d_scene', 'add_3d_scene_layer', 'apply_3d_rhythm', 'save_3d_scene', 'export_3d_scene'], example: 'Crea una escena 2.5D, añade esta imagen, guarda la escena y expórtala.' }, + 'Studios/Video 3D': { actions: [], example: 'Montar GLB y editar cámaras del mundo 3D requiere el editor.', support: 'manual' }, + 'Studios/Replace character': { actions: [], example: 'Selecciona el vídeo y el fotograma editado en Replace character.', support: 'manual' }, + 'Studios/Animate': { actions: ['open_character_kit_rig'], example: 'Abre el rig del Character Kit. Los ajustes de animación necesitan controles manuales.', support: 'partial' }, + 'Production/Director': { actions: ['stage_story_video', 'stage_story_music_video', 'start_director_production'], example: 'Prepara el videoclip de la canción seleccionada y lanza la producción.' }, + 'Production/Video Editor': { actions: ['create_video_editor_project', 'add_video_editor_clips', 'trim_video_editor_clip', 'add_video_editor_audio', 'export_video_editor'], example: 'Crea un montaje con estos dos vídeos, añade esta canción y expórtalo.' }, + 'Workspaces': { actions: ['create_workspace_collection', 'update_workspace_collection'], example: 'Crea una colección de trabajo con estos assets y una nota sobre el proyecto.' }, + 'Activity': { actions: ['inspect_queue', 'cancel_task', 'retry_task', 'resume_task'], example: 'Muestra la cola y el estado de las tareas de esta carpeta.' }, + 'Settings': { actions: ['open_tab', 'download_model'], example: 'Abre Settings. Los proveedores, preferencias y credenciales se configuran manualmente.', support: 'partial' }, + 'Mobile navigation': { actions: [], example: 'Desliza la fila de secciones y pulsa el destino deseado.', support: 'manual' }, +} + +export function wizardEvidence(route: string[]): FeatureEvidence['wizard'] { + const key = route.join('/'), entry = wizardRoutes[key] + if (entry) return { ...entry, support: entry.support ?? 'registered', note: 'Contrato inspeccionado en el registro de capacidades. La captura verifica UI; sólo un caso con traza y resultado acredita ejecución real del Wizard.' } + if (route[0] === 'Direct generation' && route[1] === 'Edit') return { support: 'manual', actions: [], example: `Selecciona la fuente y ajusta ${route.at(-1)} en el panel de edición.`, note: 'No se identifica una capacidad dedicada prepare_edit en el registro inspeccionado.' } + return { support: 'partial', actions: ['open_tab'], example: `Abre ${route.at(-1)}; algunos filtros se ajustan manualmente.`, note: 'Navegación parcial. No se certifica una acción específica para cada filtro con esta captura.' } +} + +export async function openAuditApp(page: Page, workspace?: string) { + page.setDefaultTimeout(20_000) + await page.goto('/') + await page.getByRole('button', { name: 'Skip', exact: true }).click({ timeout: 8_000 }).catch(() => undefined) + await expect(page.getByRole('button', { name: 'Studios', exact: true })).toBeVisible() + if (workspace) await expect(page.getByRole('button', { name: `Switch output folder: ${workspace}`, exact: true })).toBeVisible() + const close = page.getByRole('button', { name: 'Close Ask to the Wizard', exact: true }) + await close.waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined) + // The panel's entrance animation can keep the visible icon moving for a + // moment after the main navigation is ready. This is test setup, so force + // the already-resolved button click instead of misreporting a tour failure. + if (await close.isVisible()) await close.click({ force: true }) +} + +export async function captureFeature(page: Page, info: TestInfo, records: FeatureEvidence[], route: string[], action: () => Promise) { + const id = String(records.length + 1).padStart(2, '0') + '-' + route.join('-').toLowerCase().replace(/[^a-z0-9]+/g, '-') + const screenshot = `${id}.png` + let error: string | undefined + try { await action() } catch (cause) { error = cause instanceof Error ? cause.message : String(cause) } + await page.screenshot({ path: info.outputPath(screenshot), fullPage: true, animations: 'disabled' }) + const record: FeatureEvidence = { id, title: route.at(-1)!, route, screenshot, status: error ? 'failed' : 'passed', error, wizard: wizardEvidence(route) } + records.push(record) + await fs.writeFile(info.outputPath('features.json'), JSON.stringify(records, null, 2)) + console.log(`[feature] ${record.status}: ${route.join(' → ')}`) + expect.soft(error, route.join(' → ')).toBeUndefined() +} diff --git a/ui/e2e/helpers/liveMedia.ts b/ui/e2e/helpers/liveMedia.ts new file mode 100644 index 00000000..c0440ba6 --- /dev/null +++ b/ui/e2e/helpers/liveMedia.ts @@ -0,0 +1,78 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import { expect, type APIRequestContext, type Page, type TestInfo } from '@playwright/test' +import { liveJson } from './liveRead' + +type Task = { id: string; parent_id?: string; status: string; result_refs?: string[]; metadata?: Record } +type Output = { name: string; type: string; url: string; size: number } + +export async function directMode(page: Page, name: string) { + const primary = page.getByRole('button', { name: 'Direct generation', exact: true }) + if (await primary.getAttribute('data-navigation-expanded') !== 'true') await primary.click() + await page.locator('[role="tablist"][data-navigation-category="direct-generation"]').getByRole('tab', { name, exact: true }).click() +} + +export async function selectModel(page: Page, name: RegExp) { + const selector = page.locator('[data-wizard-anchor="model"]') + await selector.getByRole('button').first().click() + await selector.getByRole('button', { name }).last().click() + await expect(selector.getByRole('button').first()).toContainText(name) +} + +async function tasks(request: APIRequestContext, workspace: string): Promise { + return (await liveJson(request, `/api/v1/tasks?status=all&workspace=${encodeURIComponent(workspace)}`)).tasks +} + +/** Observe actual UI submissions and server results. Never execute a store action. */ +export async function generateAndVerify(page: Page, request: APIRequestContext, info: TestInfo, workspace: string, label: string, submit: () => Promise, kind: string) { + const before = new Set((await tasks(request, workspace)).map(task => task.id)) + const samples: unknown[] = [] + let task: Task | undefined + await page.screenshot({ path: info.outputPath(`${label}-form.png`), fullPage: true }) + const initial = await liveJson(request, '/api/v1/system-stats') + samples.push({ at: new Date().toISOString(), ...initial }) + expect(initial.ram.percent, 'Do not start inference while the host is already under memory pressure').toBeLessThan(80) + const submission = page.waitForResponse(response => response.request().method() === 'POST' && /\/api\/v1\/(generate|tools\/)/.test(response.url()), { timeout: 30_000 }) + await submit() + const accepted = await submission + expect(accepted.ok(), `Submission HTTP ${accepted.status()}: ${await accepted.text()}`).toBeTruthy() + try { + await expect.poll(async () => { + task = (await tasks(request, workspace)).find(item => !item.parent_id && !before.has(item.id)) + return task?.id + }, { timeout: 60_000, intervals: [1000, 2000] }).toBeTruthy() + const id = task!.id + console.log(`[generation] ${label}: ${workspace} / ${id}`) + await page.screenshot({ path: info.outputPath(`${label}-queued.png`), fullPage: true }) + await expect.poll(async () => { + task = (await tasks(request, workspace)).find(item => item.id === id) + samples.push({ at: new Date().toISOString(), ...await liveJson(request, '/api/v1/system-stats') }) + await fs.writeFile(info.outputPath(`${label}-resources.json`), JSON.stringify(samples, null, 2)) + return task?.status + }, { timeout: 20 * 60_000, intervals: [5000, 10_000] }).toMatch(/^(completed|failed|cancelled|interrupted)$/) + expect(task?.status, JSON.stringify(task)).toBe('completed') + const result = await request.get(`/api/v1/outputs?workspace=${encodeURIComponent(workspace)}`) + const outputs: Output[] = (await result.json()).outputs + const names = task!.result_refs || [] + const output = outputs.find(item => names.includes(item.name) && item.type === kind) + expect(output, 'Canonical result must resolve to a published output of the requested kind').toBeTruthy() + const media = await request.get(output!.url) + expect(media.ok()).toBeTruthy() + const bytes = await media.body() + expect(bytes.length).toBeGreaterThan(256) + const metadataResponse = await request.get(`/api/v1/outputs/${encodeURIComponent(output!.name)}/metadata?workspace=${encodeURIComponent(workspace)}`) + expect(metadataResponse.ok()).toBeTruthy() + const metadata = await metadataResponse.json() + expect(metadata.execution?.mode, 'Output must declare the selected execution profile').toBe(process.env.HOCUSPOCUS_E2E_PROFILE || 'simulate') + const serialized = JSON.stringify(metadata) + expect(serialized).not.toContain('"simulated":true') + await fs.writeFile(info.outputPath(`${label}-result.json`), JSON.stringify({ workspace, task, output, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), metadata }, null, 2)) + await fs.writeFile(info.outputPath(output!.name), bytes) + await page.screenshot({ path: info.outputPath(`${label}-completed.png`), fullPage: true }) + return output! + } finally { + await fs.writeFile(info.outputPath(`${label}-resources.json`), JSON.stringify(samples, null, 2)) + const finalTasks = await tasks(request, workspace).catch(error => ({ observationError: String(error) })) + await fs.writeFile(info.outputPath(`${label}-tasks.json`), JSON.stringify(finalTasks, null, 2)) + } +} diff --git a/ui/e2e/helpers/liveRead.ts b/ui/e2e/helpers/liveRead.ts new file mode 100644 index 00000000..5711aac1 --- /dev/null +++ b/ui/e2e/helpers/liveRead.ts @@ -0,0 +1,22 @@ +import type { APIRequestContext } from '@playwright/test' + +/** Retry observations only. Submission/cancellation must never be replayed. */ +export async function liveJson( + request: Pick, + path: string, + options: { timeout?: number } = {}, +) { + const timeout = options.timeout ?? 20_000 + for (let attempt = 0; ; attempt += 1) { + let response + try { + response = await request.get(path, { timeout }) + } catch (error) { + if (attempt >= 3 || !/ECONNRESET|ECONNREFUSED|socket hang up|closed before receiving/i.test(String(error))) throw error + await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1))) + continue + } + if (!response.ok()) throw Error(`${path}: HTTP ${response.status()} ${await response.text()}`) + return await response.json() + } +} diff --git a/ui/e2e/helpers/liveWorkspace.ts b/ui/e2e/helpers/liveWorkspace.ts new file mode 100644 index 00000000..cdc40f19 --- /dev/null +++ b/ui/e2e/helpers/liveWorkspace.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' +import { expect, type APIRequestContext, type Page, type TestInfo } from '@playwright/test' +import { isOwnedWorkspace, liveQueryViolation, liveTaskTarget, liveWriteViolation } from './liveWorkspacePolicy' +import { liveJson } from './liveRead' + +export interface LiveSystemConfig { + execution_mode?: string + execution_workspace?: string + execution_simulation_step_delay?: number +} + +const workspaces = new WeakMap() +const expectedMode = process.env.HOCUSPOCUS_E2E_PROFILE || 'simulate' +const runPrefix = process.env.HOCUSPOCUS_E2E_WORKSPACE || `e2e_acceptance_${Date.now().toString(36)}` + +export async function liveConfig(request: APIRequestContext, info: TestInfo): Promise { + const response = await request.get('/api/v1/system-config') + expect(response.ok(), `system-config HTTP ${response.status()}`).toBeTruthy() + const config = await response.json() as LiveSystemConfig + expect(config.execution_mode).toBe(expectedMode) + if (!workspaces.has(info)) { + const suffix = createHash('sha256').update(info.testId).digest('hex').slice(0, 8) + const chosen = expectedMode === 'real' ? `${runPrefix}_${suffix}_${Date.now().toString(36)}` : config.execution_workspace + if (!chosen || !/^e2e[_-][a-zA-Z0-9_-]+$/.test(chosen)) throw Error('Acceptance needs an e2e_ test workspace') + workspaces.set(info, chosen) + } + return { ...config, execution_workspace: workspaces.get(info) } +} + +export async function isolateLiveWorkspace(page: Page, request: APIRequestContext, info: TestInfo) { + const config = await liveConfig(request, info) + if (expectedMode === 'real') expect(process.env.HOCUSPOCUS_E2E_CONFIRM_REAL).toBe('YES') + const workspace = config.execution_workspace! + const listingResponse = await request.get('/api/v1/workspaces') + expect(listingResponse.ok()).toBeTruthy() + const listing = await listingResponse.json() as { active: string; workspaces: Array<{ name: string }> } + if (!listing.workspaces.some(item => item.name === workspace)) { + const created = await request.post('/api/v1/workspaces', { data: { name: workspace } }) + expect(created.ok(), `create workspace HTTP ${created.status()}`).toBeTruthy() + } + let selected = workspace + const intercepted: Array<{ method: string; path: string; reason: string }> = [] + const nativeWrites: Array<{ method: string; path: string }> = [] + const browserPreferences = new Map>() + const preferencePaths = new Set(['/api/v1/model-selections', '/api/v1/model-visibility', '/api/v1/production-profile']) + await page.route('**/api/v1/**', async route => { + const req = route.request(), url = new URL(req.url()), method = req.method() + if (url.pathname === '/api/v1/jobs/recovery' && method === 'GET') { + const response = await route.fetch() + const data = await response.json() + intercepted.push({ method, path: url.pathname, reason: 'recovery listing restricted to this test workspace; saved queue preserved' }) + await route.fulfill({ response, json: { ...data, jobs: data.jobs.filter((job: { workspace?: string }) => isOwnedWorkspace(job.workspace, workspace)) } }) + return + } + if (url.pathname === '/api/v1/workspaces' && method === 'GET') { + const response = await route.fetch() + const data = await response.json() + await route.fulfill({ response, json: { ...data, active: selected } }) + return + } + let payload: unknown + try { payload = req.postDataJSON() } catch { /* upload/form requests retain their original body */ } + if (preferencePaths.has(url.pathname)) { + if (method === 'PUT') { + const baseline = browserPreferences.get(url.pathname) ?? await (await request.get(url.pathname)).json() + const value = { ...baseline, ...payload as object, configured: true } + browserPreferences.set(url.pathname, value) + intercepted.push({ method, path: url.pathname, reason: 'browser-only test preference; global preference preserved' }) + await route.fulfill({ json: value }) + return + } + if (method === 'GET' && browserPreferences.has(url.pathname)) { + await route.fulfill({ json: browserPreferences.get(url.pathname) }) + return + } + } + if (url.pathname === '/api/v1/workspaces/active' && method === 'PUT') { + const name = (payload as { name?: unknown })?.name + if (isOwnedWorkspace(name, workspace)) { + const response = await request.get('/api/v1/workspaces') + const data = await response.json() as { workspaces: Array<{ name: string }> } + if (data.workspaces.some(item => item.name === name)) { + selected = name + intercepted.push({ method, path: url.pathname, reason: 'browser-only test selection; server active folder preserved' }) + await route.fulfill({ json: { status: 'ok', active: selected } }) + return + } + } + } + let reason = liveWriteViolation(method, url.pathname, payload, workspace) || liveQueryViolation(method, url.searchParams, workspace) + const id = liveTaskTarget(method, url.pathname) + if (!reason && id) { + const response = await request.get(`/api/v1/tasks?workspace=${encodeURIComponent(selected)}&status=all`) + const data = await response.json() as { tasks: Array<{ id: string; backend_job_id?: string; pipeline_id?: string; workspace?: string }> } + if (!response.ok() || !data.tasks?.some(task => isOwnedWorkspace(task.workspace, workspace) && [task.id, task.backend_job_id, task.pipeline_id].includes(id))) reason = 'task mutation outside the selected test workspace' + } + if (reason) { + intercepted.push({ method, path: url.pathname, reason }) + await route.fulfill({ status: 409, json: { detail: `Acceptance isolation: ${reason}` } }) + return + } + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) nativeWrites.push({ method, path: url.pathname }) + await route.continue() + }) + await page.addInitScript(() => { + localStorage.setItem('hocuspocus_welcome_seen_v1', '1') + localStorage.setItem('hocuspocus-ui-language', 'en') + }) + return { + workspace, + selected: () => selected, + async evidence() { + const current = await liveJson(request, '/api/v1/workspaces').catch(error => ({ observationError: String(error) })) + await info.attach('workspace-isolation', { body: JSON.stringify({ workspace, serverActiveBefore: listing.active, serverActiveAfter: current.active ?? null, observationError: current.observationError, selectedInBrowser: selected, intercepted, nativeWrites, note: 'Workspace selection and shared model/profile preferences are confined to this browser. Their server persistence is not tested. Model inference, Wizard LLM, queue, project persistence and media are live. Test outputs are preserved.' }, null, 2), contentType: 'application/json' }) + }, + } +} diff --git a/ui/e2e/helpers/liveWorkspacePolicy.ts b/ui/e2e/helpers/liveWorkspacePolicy.ts new file mode 100644 index 00000000..8686ee9f --- /dev/null +++ b/ui/e2e/helpers/liveWorkspacePolicy.ts @@ -0,0 +1,49 @@ +/** Harness isolation only: media and Wizard requests still use the live backend. */ +export function isOwnedWorkspace(name: unknown, prefix: string): name is string { + return typeof name === 'string' && (name === prefix || name.startsWith(`${prefix}_`)) +} + +/** IDs in legacy control routes still have to resolve to an owned canonical task. */ +export function liveTaskTarget(method: string, pathname: string): string | null { + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return null + const patterns = [ + /^\/api\/v1\/cancel\/([^/]+)$/, + /^\/api\/v1\/tasks\/([^/]+)(?:\/(?:cancel|retry|resume))?$/, + /^\/api\/v1\/stories\/generate\/(?:cancel|resume)\/([^/]+)$/, + /^\/api\/v1\/stories\/music-candidates\/jobs\/([^/]+)\/(?:cancel|resume|retry)$/, + /^\/api\/v1\/series\/(?:plan|render|assembly)\/jobs\/([^/]+)(?:\/(?:cancel|resume|retry|apply|apply-canon))?$/, + /^\/api\/v1\/director\/pipeline\/([^/]+)\/(?:stop|cancel|resume|retry)$/, + /^\/api\/v1\/video-editor\/export\/([^/]+)\/(?:cancel|resume|retry)$/, + ] + for (const pattern of patterns) { + const match = pattern.exec(pathname) + if (match) return decodeURIComponent(match[1]) + } + return null +} + +export function liveQueryViolation(method: string, params: URLSearchParams, prefix: string): string | null { + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return null + for (const key of ['workspace', 'workspace_id', 'output_folder']) { + if (params.getAll(key).some(value => !isOwnedWorkspace(value, prefix))) return `query outside test workspace: ${key}` + } + return null +} + +export function liveWriteViolation(method: string, pathname: string, payload: unknown, prefix: string): string | null { + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return null + if (method === 'DELETE') return 'deletion is excluded; preserve existing data and test evidence' + if (pathname.startsWith('/api/v1/jobs/recovery/')) return 'global queue recovery is excluded; preserve interrupted jobs' + if (pathname.startsWith('/api/v1/system/')) return 'global runtime controls are excluded' + if (pathname === '/api/v1/comics' || pathname === '/api/v1/comics/history' || (method === 'PUT' && /^\/api\/v1\/comics\/[^/]+$/.test(pathname))) return 'legacy comic saves use the server active folder; use browser JSON/PDF export for this audit' + if (/^\/api\/v1\/(?:system-config|services-config|config|settings|models|model-selections|model-visibility|model-folders|production-profile)(?:\/|$)/.test(pathname)) return 'global configuration or model mutation' + if (pathname === '/api/v1/workspaces/active') return 'global workspace activation must stay in the browser harness' + const body = payload && typeof payload === 'object' ? payload as Record : {} + if (method === 'POST' && /^\/api\/v1\/(?:generate|tools\/(?:upscale|revoice|remove-background)|comics\/generate\/minimax(?:\/jobs)?)$/.test(pathname) && !isOwnedWorkspace(body.workspace, prefix)) return 'submission needs an explicit owned workspace in its JSON body' + for (const key of ['workspace', 'workspace_id', 'output_folder']) { + if (body[key] != null && !isOwnedWorkspace(body[key], prefix)) return `write outside test workspace: ${key}` + } + if (pathname === '/api/v1/workspaces' && !isOwnedWorkspace(body.name, prefix)) return 'workspace creation outside test prefix' + if (/(?:\/)(?:cancel|resume|retry|retry-item|stop|discard)(?:\/|$)/.test(pathname) && !liveTaskTarget(method, pathname)) return 'unrecognized job control is excluded from shared live acceptance' + return null +} diff --git a/ui/e2e/live-specs/app-features.spec.ts b/ui/e2e/live-specs/app-features.spec.ts new file mode 100644 index 00000000..3e68508f --- /dev/null +++ b/ui/e2e/live-specs/app-features.spec.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs/promises' +import { expect, test } from '@playwright/test' +import { captureFeature, openAuditApp, type FeatureEvidence } from '../helpers/appAudit' +import { isolateLiveWorkspace } from '../helpers/liveWorkspace' + +test('app: feature tour captures every main destination and tool panel', async ({ page, request }, info) => { + const isolation = await isolateLiveWorkspace(page, request, info) + const records: FeatureEvidence[] = [] + const errors: string[] = [] + const closeDirectorIfOpen = async () => { + const close = page.getByRole('button', { name: 'Close Director video workflows', exact: true }) + if (await close.isVisible()) await close.click({ force: true }) + } + page.on('pageerror', error => errors.push(error.message)) + try { + await openAuditApp(page, isolation.workspace) + for (const [category, label] of [['direct-generation', 'Direct generation'], ['studios', 'Studios'], ['production', 'Production'], ['media', 'Media']]) { + const primary = page.getByRole('button', { name: label, exact: true }) + if (await primary.getAttribute('data-navigation-expanded') !== 'true') await primary.click() + const tabs = page.locator(`[role="tablist"][data-navigation-category="${category}"]`) + await expect(tabs).toBeVisible() + const names = await tabs.getByRole('tab').evaluateAll(items => items.map(item => item.getAttribute('aria-label')!)) + for (const name of names) { + await captureFeature(page, info, records, [label, name], async () => { + if (await primary.getAttribute('data-navigation-expanded') !== 'true') await primary.click() + const tab = tabs.getByRole('tab', { name, exact: true }) + await tab.click() + await expect(tab).toHaveAttribute('aria-selected', 'true') + await page.evaluate(() => document.fonts.ready) + }) + await closeDirectorIfOpen() + if (category === 'direct-generation' && name === 'Tools') { + for (const tool of ['Upscale', 'Revoice', 'Remove background']) { + await captureFeature(page, info, records, [label, name, tool], async () => { + await page.getByRole('button', { name: tool, exact: true }).click() + }) + await closeDirectorIfOpen() + } + } + const subModes = name === 'Audio' ? ['Speech', 'Music', 'SFX', 'Mixer'] + : name === 'Video' ? ['Frames', 'Multi-Shot', 'Extend', 'Blend'] + : name === 'Edit' ? ['Retake', 'Edit Anything', 'Outpaint', 'Repaint', 'Recast'] : [] + if (category === 'direct-generation') for (const subMode of subModes) { + await captureFeature(page, info, records, [label, name, subMode], async () => { + await page.getByRole('button', { name: subMode, exact: true }).click() + }) + await closeDirectorIfOpen() + } + } + } + for (const name of ['Workspaces', 'Activity']) { + await captureFeature(page, info, records, [name], async () => { + await page.getByRole(name === 'Activity' ? 'button' : 'tab', { name, exact: true }).click() + }) + await closeDirectorIfOpen() + } + await captureFeature(page, info, records, ['Settings'], async () => { + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await expect(page.getByText('Storage Manager', { exact: true })).toBeVisible() + }) + await closeDirectorIfOpen() + await page.getByRole('button', { name: 'Close settings', exact: true }).click() + const settingsDrawer = page.locator('div.fixed.top-0.right-0').filter({ has: page.getByRole('button', { name: 'Close settings', exact: true }) }) + await expect(settingsDrawer).not.toBeInViewport() + const activity = page.getByRole('button', { name: 'Activity', exact: true }) + if (await activity.getAttribute('aria-expanded') === 'true') await activity.click() + await page.setViewportSize({ width: 390, height: 844 }) + await captureFeature(page, info, records, ['Mobile navigation'], async () => { + // The responsive Wizard is an overlay. It can reopen when the viewport + // crosses the mobile breakpoint, so close it before exercising the + // underlying navigation; otherwise the overlay intercepts the click. + const mobileClose = page.getByRole('button', { name: 'Close Ask to the Wizard', exact: true }) + if (await mobileClose.isVisible()) await mobileClose.click({ force: true }) + await page.getByRole('button', { name: 'Studios', exact: true }).click() + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(2) + }) + } finally { + await isolation.evidence() + await fs.writeFile(info.outputPath('page-errors.json'), JSON.stringify(errors, null, 2)) + await info.attach('feature-inventory', { body: JSON.stringify(records, null, 2), contentType: 'application/json' }) + } + expect(errors).toEqual([]) +}) diff --git a/ui/e2e/live-specs/app-generation.spec.ts b/ui/e2e/live-specs/app-generation.spec.ts new file mode 100644 index 00000000..9e1626bc --- /dev/null +++ b/ui/e2e/live-specs/app-generation.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test' +import { openAuditApp } from '../helpers/appAudit' +import { isolateLiveWorkspace } from '../helpers/liveWorkspace' +import { directMode, generateAndVerify, selectModel } from '../helpers/liveMedia' + +test('app: real image generation and image upscale through visible controls', async ({ page, request }, info) => { + const isolation = await isolateLiveWorkspace(page, request, info) + try { + await openAuditApp(page, isolation.workspace) + await directMode(page, 'Image') + await selectModel(page, /Flux 2 Klein 9B/i) + await page.getByPlaceholder('Describe your image...').fill('A small handcrafted toy wizard with a cobalt blue coat holding a glowing orange keyboard, full body, clean white studio background, soft shadows, no text.') + const source = await generateAndVerify(page, request, info, isolation.workspace, 'image', async () => { + await page.locator('[data-wizard-anchor="generate"]').click() + }, 'image') + await directMode(page, 'Tools') + await page.getByRole('button', { name: 'Upscale', exact: true }).click() + await page.getByRole('button', { name: 'Use selected gallery image', exact: true }).click() + await page.locator('select').filter({ has: page.locator('option[value="lanczos2"]') }).selectOption('lanczos2') + await expect(page.getByRole('button', { name: 'Upscale Image', exact: true })).toBeEnabled() + const enlarged = await generateAndVerify(page, request, info, isolation.workspace, 'upscale', async () => { + await page.getByRole('button', { name: 'Upscale Image', exact: true }).click() + }, 'image') + const dimensions = await page.evaluate(async urls => Promise.all(urls.map(url => new Promise<{ width: number; height: number }>((resolve, reject) => { + const image = new Image() + image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }) + image.onerror = () => reject(Error(`Cannot decode ${url}`)) + image.src = url + }))), [source.url, enlarged.url]) + expect(dimensions[1]).toEqual({ width: dimensions[0].width * 2, height: dimensions[0].height * 2 }) + } finally { await isolation.evidence() } +}) + +test('app: real instrumental music generation through visible controls', async ({ page, request }, info) => { + const isolation = await isolateLiveWorkspace(page, request, info) + try { + await openAuditApp(page, isolation.workspace) + const defaults = page.waitForResponse(response => /\/api\/v1\/defaults\/ace_step/.test(response.url()) && response.ok()) + await directMode(page, 'Audio') + await defaults + await page.getByRole('button', { name: 'Music', exact: true }).click() + await page.getByRole('checkbox', { name: 'Instrumental', exact: true }).check() + await page.getByPlaceholder(/Genre, instruments/i).fill('Playful chiptune instrumental, warm synth bass, bright arpeggios, 110 BPM, a wizard programming at midnight, no vocals.') + await page.getByRole('slider').first().fill('30') + await expect(page.getByRole('slider').first()).toHaveValue('30') + const music = await generateAndVerify(page, request, info, isolation.workspace, 'music', async () => { + await page.locator('[data-wizard-anchor="generate"]').click() + }, 'audio') + const duration = await page.evaluate(url => new Promise((resolve, reject) => { + const audio = new Audio() + audio.onloadedmetadata = () => resolve(audio.duration) + audio.onerror = () => reject(Error(`Cannot decode ${url}`)) + audio.src = url + }), music.url) + expect(duration).toBeGreaterThan(28) + expect(duration).toBeLessThan(32) + } finally { await isolation.evidence() } +}) diff --git a/ui/e2e/live-specs/wizard-generation.spec.ts b/ui/e2e/live-specs/wizard-generation.spec.ts index 6386d624..e0ad6cf2 100644 --- a/ui/e2e/live-specs/wizard-generation.spec.ts +++ b/ui/e2e/live-specs/wizard-generation.spec.ts @@ -1,45 +1,13 @@ import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test' +import { isolateLiveWorkspace, liveConfig } from '../helpers/liveWorkspace' +import { liveJson as json } from '../helpers/liveRead' +import fs from 'node:fs/promises' +import type { ComicProject } from '../../src/features/comics/types' +import type { SeriesLibrary } from '../../src/features/series/types' const scenario = process.env.HOCUSPOCUS_E2E_SCENARIO || 'smoke' const expectedMode = process.env.HOCUSPOCUS_E2E_PROFILE || 'simulate' -interface SystemConfig { - execution_mode?: string - execution_workspace?: string - execution_simulation_step_delay?: number -} - -async function json(request: APIRequestContext, path: string) { - let lastError: unknown - for (let attempt = 0; attempt < 4; attempt += 1) { - try { - const response = await request.get(path) - expect(response.ok(), `${path}: ${response.status()} ${await response.text()}`).toBeTruthy() - return await response.json() - } catch (error) { - lastError = error - const transient = /ECONNRESET|ECONNREFUSED|socket hang up|closed before receiving/i.test(String(error)) - if (!transient || attempt === 3) throw error - await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1))) - } - } - throw lastError -} - -async function prepareWorkspace(request: APIRequestContext): Promise { - const config = await json(request, '/api/v1/system-config') as SystemConfig - expect(config.execution_mode).toBe(expectedMode) - const workspace = config.execution_workspace - expect(workspace).toBeTruthy() - const listing = await json(request, '/api/v1/workspaces') as { workspaces: Array<{ name: string }> } - if (!listing.workspaces.some(item => item.name === workspace)) { - const created = await request.post('/api/v1/workspaces', { data: { name: workspace } }) - expect(created.ok(), await created.text()).toBeTruthy() - } - const selected = await request.put('/api/v1/workspaces/active', { data: { name: workspace } }) - expect(selected.ok(), await selected.text()).toBeTruthy() - return config -} function wizardPanel(page: Page) { return page.locator( @@ -48,6 +16,7 @@ function wizardPanel(page: Page) { } async function openApp(page: Page) { + page.setDefaultTimeout(20_000) await page.addInitScript(() => { window.localStorage.setItem('hocuspocus_welcome_seen_v1', '1') }) @@ -60,13 +29,11 @@ async function openApp(page: Page) { await expect(page.getByTestId('execution-mode-banner')).toContainText(expectedMode) } const panel = wizardPanel(page) - if (!await panel.isVisible()) { - const expand = page.getByRole('button', { name: 'Expand Ask to the Wizard' }) - if (await expand.isVisible()) await expand.click() - else await page.getByTitle('Ask to the Wizard about the app or current task queue').click() + const opened = await panel.waitFor({ state: 'visible', timeout: 20_000 }).then(() => true).catch(() => false) + if (!opened) { + await page.getByRole('button', { name: 'Expand Ask to the Wizard' }).click() } await expect(panel).toBeVisible() - await panel.getByRole('button', { name: 'Clear Ask to the Wizard conversation' }).click() await expect(panel.getByText('Saludos, creador. Soy el mago de HocusPocus', { exact: false })).toBeVisible() } @@ -74,8 +41,10 @@ async function ask(page: Page, prompt: string, options: { allowFailure?: boolean const panel = wizardPanel(page) const input = panel.getByPlaceholder('Ask HocusPocus for a spell…') await input.fill(prompt) + const responsePending = page.waitForResponse(response => response.url().endsWith('/api/v1/llm/generate') && response.request().method() === 'POST', { timeout: 120_000 }) await panel.getByRole('button', { name: 'Ask to the Wizard', exact: true }).click() - await expect(input).toBeDisabled() + const response = await responsePending + expect(response.ok(), `Wizard LLM HTTP ${response.status()}: ${await response.text()}`).toBeTruthy() await expect(input).toBeEnabled({ timeout: 25 * 60_000 }) const transcript = (await panel.textContent()) || '' expect(transcript).not.toContain('No he podido consultar el LLM') @@ -158,9 +127,13 @@ async function waitForTerminalRoot( previous: Set, expectedStatus: 'terminal' | 'completed' = 'terminal', ) { + // Cold-loading ACE-Step can briefly block the API worker while the model is + // moved into reserved RAM. Keep observing the same task rather than + // turning that transient lack of an HTTP response into a duplicate run. + const taskJson = (path: string) => json(request, path, { timeout: 180_000 }) let taskId = '' await expect.poll(async () => { - const payload = await json(request, `/api/v1/tasks?status=all&workspace=${encodeURIComponent(workspace)}`) as { + const payload = await taskJson(`/api/v1/tasks?status=all&workspace=${encodeURIComponent(workspace)}`) as { tasks: Array<{ id: string; parent_id?: string | null }> } taskId = payload.tasks.find(item => !item.parent_id && !previous.has(item.id))?.id || '' @@ -168,7 +141,7 @@ async function waitForTerminalRoot( }, { timeout: 60_000, intervals: [250, 500, 1_000, 2_000] }).not.toBe('') let terminalStatus = '' await expect.poll(async () => { - const payload = await json(request, `/api/v1/tasks?status=all&workspace=${encodeURIComponent(workspace)}`) as { + const payload = await taskJson(`/api/v1/tasks?status=all&workspace=${encodeURIComponent(workspace)}`) as { tasks: Array<{ id: string; parent_id?: string | null; status: string }> } terminalStatus = payload.tasks.find(item => item.id === taskId)?.status || '' @@ -178,8 +151,100 @@ async function waitForTerminalRoot( return taskId } +type WizardMediaTask = { + id: string + status: string + kind: string + model?: string + result_refs?: string[] + metadata?: Record + created_at?: number + started_at?: number | null + completed_at?: number | null +} + +type WizardMediaOutput = { + name: string + type: string + url: string + size: number +} + +/** + * Resolve a real Wizard-submitted task through the canonical output registry. + * This deliberately observes the task and downloads its published bytes; it + * never submits a second request or calls a capability executor directly. + */ +async function waitForWizardMedia( + page: Page, + request: APIRequestContext, + info: TestInfo, + workspace: string, + previous: Set, + label: string, + kind: 'image' | 'audio', +) { + const taskId = await waitForTerminalRoot(request, workspace, previous, 'completed') + const taskPayload = await json(request, `/api/v1/tasks?status=all&workspace=${encodeURIComponent(workspace)}`) as { + tasks: WizardMediaTask[] + } + const task = taskPayload.tasks.find(item => item.id === taskId) + expect(task, `Canonical ${label} task must remain observable`).toBeTruthy() + expect(task?.metadata?.actor).toBe('wizard') + expect(task?.metadata?.capability).toBe('start_generation') + expect(task?.result_refs?.length).toBeGreaterThan(0) + + const outputsPayload = await json(request, `/api/v1/outputs?workspace=${encodeURIComponent(workspace)}`) as { + outputs: WizardMediaOutput[] + } + const output = outputsPayload.outputs.find(item => ( + item.type === kind && task?.result_refs?.includes(item.name) + )) + expect(output, `Canonical ${label} output must be published`).toBeTruthy() + const mediaResponse = await request.get(output!.url) + expect(mediaResponse.ok(), `${label} output must be downloadable`).toBeTruthy() + const bytes = await mediaResponse.body() + expect(bytes.length).toBeGreaterThan(256) + const metadataResponse = await request.get( + `/api/v1/outputs/${encodeURIComponent(output!.name)}/metadata?workspace=${encodeURIComponent(workspace)}`, + ) + expect(metadataResponse.ok(), `${label} metadata must be readable`).toBeTruthy() + const metadata = await metadataResponse.json() as Record + expect((metadata.origin as Record | undefined)?.actor).toBe('wizard') + expect((metadata.execution as Record | undefined)?.status).toBe('completed') + expect((metadata.execution as Record | undefined)?.mode).toBe('real') + + const decoded = await page.evaluate(({ url, mediaKind }) => new Promise>((resolve, reject) => { + if (mediaKind === 'image') { + const image = new Image() + image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }) + image.onerror = () => reject(new Error('Browser could not decode Wizard image output')) + image.src = url + return + } + const audio = new Audio() + audio.onloadedmetadata = () => resolve({ duration: audio.duration }) + audio.onerror = () => reject(new Error('Browser could not decode Wizard audio output')) + audio.src = url + }), { url: output!.url, mediaKind: kind }) + expect(Object.values(decoded).every(value => Number.isFinite(value) && value > 0)).toBeTruthy() + + const started = Number(task?.started_at || task?.created_at || 0) + const completed = Number(task?.completed_at || 0) + const timing = { + started_at: task?.started_at ?? null, + completed_at: task?.completed_at ?? null, + elapsed_seconds: started > 0 && completed > started ? completed - started : null, + } + await info.attach(`${label}-task.json`, { body: JSON.stringify(task, null, 2), contentType: 'application/json' }) + await info.attach(`${label}-metadata.json`, { body: JSON.stringify(metadata, null, 2), contentType: 'application/json' }) + await info.attach(`${label}-timing.json`, { body: JSON.stringify({ timing, decoded, bytes: bytes.length }, null, 2), contentType: 'application/json' }) + await info.attach(`${label}-output${kind === 'image' ? '.png' : '.wav'}`, { body: bytes, contentType: kind === 'image' ? 'image/png' : 'audio/wav' }) + return { task, output, metadata, bytes, decoded, timing } +} + async function attachEvidence(page: Page, request: APIRequestContext, testInfo: TestInfo, transcript: string) { - const config = await json(request, '/api/v1/system-config') as SystemConfig + const config = await liveConfig(request, test.info()) const workspace = String(config.execution_workspace) const [tasks, stories] = await Promise.all([ json(request, `/api/v1/tasks?status=all&workspace=${encodeURIComponent(workspace)}`), @@ -195,17 +260,33 @@ async function attachEvidence(page: Page, request: APIRequestContext, testInfo: await testInfo.attach('final-ui.png', { body: await page.screenshot({ fullPage: true }), contentType: 'image/png' }) } -test.beforeEach(async ({ page, request }) => { - const config = await prepareWorkspace(request) - if (config.execution_mode === 'real') { - expect(process.env.HOCUSPOCUS_E2E_CONFIRM_REAL).toBe('YES') +const isolationByPage = new WeakMap>>() +test.afterEach(async ({ page, request }, info) => { + if (info.status !== 'passed' && info.status !== 'skipped') { + try { + await attachEvidence(page, request, info, (await wizardPanel(page).textContent()) || '') + } catch (error) { + await info.attach('evidence-observation-error.txt', { body: String(error), contentType: 'text/plain' }) + } } + await isolationByPage.get(page)?.evidence() +}) + +test.beforeEach(async ({ page, request }) => { + const isolation = await isolateLiveWorkspace(page, request, test.info()) + isolationByPage.set(page, isolation) + const hydrated = page.waitForResponse(response => { + const url = new URL(response.url()) + return url.pathname === '/api/v1/wizard/conversations' && url.searchParams.get('workspace') === isolation.workspace && response.request().method() === 'GET' && response.ok() + }) await openApp(page) + await hydrated + await expect(wizardPanel(page).getByText(`Workspace: ${isolation.workspace}`, { exact: true })).toBeVisible() }) test('wizard: Studio UI → canonical queue → generated video', async ({ page, request }, testInfo) => { test.skip(!['smoke', 'full', 'studio'].includes(scenario), `scenario=${scenario}`) - const config = await json(request, '/api/v1/system-config') as SystemConfig + const config = await liveConfig(request, test.info()) const before = await rootTaskIds(request, String(config.execution_workspace)) const transcript = await ask(page, expectedMode === 'plan' ? 'Abre Studio → Video y rellena visiblemente el formulario con un plano de 5 segundos de un mago programador ante servidores. No lo generes.' @@ -243,11 +324,65 @@ test('wizard: Studio UI → canonical queue → generated video', async ({ page, expect(await rootTaskIds(request, String(config.execution_workspace))).toEqual(before) } expect(transcript).toMatch(/Studio|vídeo|video/i) - await expect(page.getByRole('button', { name: 'Studio', exact: true })).toHaveClass(/bg-toggle-active/) + await expect(page.getByRole('button', { name: 'Direct generation', exact: true })).toBeVisible() await expect(page.getByPlaceholder('Describe your video...')).not.toHaveValue('') await attachEvidence(page, request, testInfo, transcript) }) +test('wizard: Ask to the Wizard real image and music outputs', async ({ page, request }, testInfo) => { + test.skip(scenario !== 'wizard-media', `scenario=${scenario}`) + const config = await liveConfig(request, test.info()) + const workspace = String(config.execution_workspace) + const imagePrompt = [ + 'Generate a fresh image now through Ask to the Wizard.', + 'Open Studio → Image and fill a fresh image generation form for one wide product-style illustration of Tentri, the LogSentinel observatory mascot, supervising amber log streams in a dark terminal room.', + 'Use the installed local Flux 2 Klein 9B model exactly when it is available, one 16:9 image.', + 'Do not reuse an existing gallery output and do not ask me for decisions.', + ].join(' ') + const beforeImage = await rootTaskIds(request, workspace) + const imageTranscript = await ask(page, imagePrompt) + const image = await waitForWizardMedia(page, request, testInfo, workspace, beforeImage, 'wizard-image', 'image') + expect(image.task.model).toMatch(/Flux 2 Klein 9B/i) + + const musicPrompt = [ + 'Generate a fresh instrumental music track now through Ask to the Wizard.', + 'Open Studio → Audio → Music and fill a fresh form for a 20-second playful chiptune observatory ident: warm synth bass, bright arpeggios, crisp terminal beeps and a confident rising finish, with no vocals.', + 'Use the installed local ACE-Step 1.5 XL SFT LM_4B model exactly when it is available, and set the duration to 20 seconds.', + 'Create a new audio task; do not reuse the image or any earlier audio output and do not ask me for decisions.', + ].join(' ') + const beforeMusic = await rootTaskIds(request, workspace) + const musicTranscript = await ask(page, musicPrompt) + const music = await waitForWizardMedia(page, request, testInfo, workspace, beforeMusic, 'wizard-music', 'audio') + expect(music.task.model).toMatch(/ACE-Step/i) + expect((music.task.metadata?.generation_details as Record | undefined)?.duration_seconds).toBe(20) + expect(music.decoded.duration).toBeGreaterThan(15) + expect(music.decoded.duration).toBeLessThan(25) + + const trace = await page.evaluate(() => ( + window as Window & { __HOCUSPOCUS_WIZARD_TRACE__?: Array> } + ).__HOCUSPOCUS_WIZARD_TRACE__ || []) as Array<{ + question?: string + turn?: { actions?: Array<{ type?: string }> } + results?: Array<{ action?: { type?: string }; command?: { commandId?: string }; report?: { taskId?: string } }> + }> + const imageTurn = trace.find(entry => entry.question === imagePrompt) + const musicTurn = trace.find(entry => entry.question === musicPrompt) + expect(imageTurn?.turn?.actions?.map(action => action.type)).toEqual(expect.arrayContaining(['prepare_image', 'start_generation'])) + expect(musicTurn?.turn?.actions?.map(action => action.type)).toEqual(expect.arrayContaining(['prepare_audio', 'start_generation'])) + for (const [entry, resultId] of [[imageTurn, image.task.id], [musicTurn, music.task.id]] as const) { + const generation = entry?.results?.find(result => result.action?.type === 'start_generation') + expect(generation?.command?.commandId).toBeTruthy() + expect(generation?.report?.taskId).toBe(resultId) + } + await testInfo.attach('wizard-image-prompt.txt', { body: imagePrompt, contentType: 'text/plain' }) + await testInfo.attach('wizard-music-prompt.txt', { body: musicPrompt, contentType: 'text/plain' }) + await testInfo.attach('wizard-media-run.json', { + body: JSON.stringify({ workspace, image, music }, null, 2), + contentType: 'application/json', + }) + await attachEvidence(page, request, testInfo, `${imageTranscript}\n\n--- MUSIC ---\n\n${musicTranscript}`) +}) + test('wizard: UI locale, conversation, content, speech and provider prompt stay independent', async ({ page, request }, testInfo) => { test.skip(!['full', 'language'].includes(scenario), `scenario=${scenario}`) const transcript = await ask(page, @@ -287,7 +422,7 @@ test('wizard: UI locale, conversation, content, speech and provider prompt stay test('wizard: vocal Spanish song → selected version → music-video Director', async ({ page, request }, testInfo) => { test.skip(!['full', 'music-video'].includes(scenario), `scenario=${scenario}`) const title = `E2E Himno Sysadmin ${Date.now()}` - const config = await json(request, '/api/v1/system-config') as SystemConfig + const config = await liveConfig(request, test.info()) const firstTranscript = await ask(page, `Crea desde cero en Story Lab un proyecto de tipo videoclip titulado exactamente "${title}". Rellena visiblemente una canción vocal completa de 20 segundos en español, heavy metal ochentero, voz ronca y coro grave, con secciones [Verse], [Chorus], [Bridge] y [Outro]. Usa ACE-Step 1.5 XL local y genera la primera versión de la canción. Todavía no prepares el videoclip. No me pidas decisiones: invéntalo todo.`, ) @@ -320,7 +455,7 @@ test('wizard: vocal Spanish song → selected version → music-video Director', test('wizard: one-turn new song request never reuses the selected music-video project', async ({ page, request }, testInfo) => { test.skip(scenario !== 'music-video-new', `scenario=${scenario}`) const title = `E2E Linus Libre ${Date.now()}` - const config = await json(request, '/api/v1/system-config') as SystemConfig + const config = await liveConfig(request, test.info()) const workspace = String(config.execution_workspace) const beforeStories = await storyProjectIds(request, workspace) const beforeTasks = await rootTaskIds(request, workspace) @@ -398,32 +533,83 @@ test('wizard: one-turn new song request never reuses the selected music-video pr test('wizard: multi-page comic is created and all panels are generated', async ({ page, request }, testInfo) => { test.skip(!['full', 'comic'].includes(scenario), `scenario=${scenario}`) const title = `E2E Comic ${Date.now()}` - const config = await json(request, '/api/v1/system-config') as SystemConfig + const config = await liveConfig(request, test.info()) const before = await rootTaskIds(request, String(config.execution_workspace)) const transcript = await ask(page, `Crea desde cero un cómic titulado exactamente "${title}" con 3 páginas y 4 viñetas distintas por página sobre una maga que repara una red encantada. Rellena la UI de Comics con todas las páginas y genera ahora todas las imágenes usando el proveedor local.`, ) - await waitForTerminalRoot(request, String(config.execution_workspace), before) + await waitForTerminalRoot(request, String(config.execution_workspace), before, 'completed') expect(transcript).toMatch(/cómic|comic|página/i) await expect(page.getByRole('tab', { name: 'Comics' })).toHaveAttribute('aria-selected', 'true') + await wizardPanel(page).getByRole('button', { name: 'Close Ask to the Wizard', exact: true }).click() + const jsonDownload = page.waitForEvent('download') + await page.getByRole('button', { name: 'JSON', exact: true }).click() + const exported = await jsonDownload + const jsonPath = testInfo.outputPath(exported.suggestedFilename()) + await exported.saveAs(jsonPath) + const comic = JSON.parse(await fs.readFile(jsonPath, 'utf8')) as ComicProject + expect(comic.title).toBe(title) + expect(comic.pages).toHaveLength(3) + for (const comicPage of comic.pages) { + const panels = comicPage.elements.filter(element => element.type === 'panel' && !element.parentId) + expect(panels).toHaveLength(4) + for (const panel of panels) { + const art = comicPage.elements.find(element => element.type === 'image' && element.parentId === panel.id) + expect(art?.type).toBe('image') + if (art?.type === 'image') expect(comic.assets[art.assetId]?.source).toBeTruthy() + } + } + expect(comic.director?.completedPanelIds).toHaveLength(12) + expect(comic.director?.failedPanelIds || []).toHaveLength(0) + const pdfDownload = page.waitForEvent('download', { timeout: 120_000 }) + await page.getByRole('button', { name: 'PDF', exact: true }).click() + const pdf = await pdfDownload + const pdfPath = testInfo.outputPath(pdf.suggestedFilename()) + await pdf.saveAs(pdfPath) + const bytes = await fs.readFile(pdfPath) + expect(bytes.subarray(0, 5).toString()).toBe('%PDF-') + expect(bytes.length).toBeGreaterThan(10_000) + await testInfo.attach('comic-export.json', { path: jsonPath, contentType: 'application/json' }) + await testInfo.attach('comic-export.pdf', { path: pdfPath, contentType: 'application/pdf' }) await attachEvidence(page, request, testInfo, transcript) }) test('wizard: Series Lab episode form is visibly populated', async ({ page, request }, testInfo) => { test.skip(!['full', 'series'].includes(scenario), `scenario=${scenario}`) const title = `E2E Episodio ${Date.now()}` - const transcript = await ask(page, - `Abre Series Lab, crea una serie de comedia tecnológica y un episodio titulado exactamente "${title}". Inventa y rellena visiblemente premisa, personajes, localizaciones, outline y al menos 4 planos. Déjalo guardado y preparado, sin generación pesada.`, + const created = await ask(page, + `Abre Series Lab, crea una serie de comedia tecnológica y un episodio titulado exactamente "${title}". Inventa y rellena visiblemente premisa, personajes, localizaciones y outline. Déjalo guardado; no generes imágenes ni vídeo.`, ) + const planned = await ask(page, `En el episodio exacto "${title}", inicia ahora el plan completo narrativo y técnico con el LLM, con al menos 4 planos. Todavía no apliques el resultado; no renderices imágenes, audio ni vídeo.`) + const trace = await page.evaluate(() => (window as Window & { __HOCUSPOCUS_WIZARD_TRACE__?: Array<{ results?: Array<{ action?: { type?: string }; report?: { taskId?: string } }> }> }).__HOCUSPOCUS_WIZARD_TRACE__ || []) + const jobId = trace.flatMap(turn => turn.results || []).find(result => result.action?.type === 'generate_series_plan')?.report?.taskId + expect(jobId).toBeTruthy() + await expect.poll(async () => (await json(request, `/api/v1/series/plan/jobs/${encodeURIComponent(jobId!)}`)).status, + { timeout: 10 * 60_000, intervals: [1000, 5000] }).toMatch(/^(completed|failed|cancelled)$/) + const plan = await json(request, `/api/v1/series/plan/jobs/${encodeURIComponent(jobId!)}`) + expect(plan.status, JSON.stringify(plan)).toBe('completed') + await testInfo.attach('completed-series-plan.json', { body: JSON.stringify(plan, null, 2), contentType: 'application/json' }) + const applied = await ask(page, `Aplica ahora al episodio exacto "${title}" la propuesta ya completada con jobId "${jobId}". No generes otra propuesta ni renderices medios.`) + const transcript = `${created}\n\n--- PLAN ---\n\n${planned}\n\n--- APPLY ---\n\n${applied}` expect(transcript).toMatch(/Series Lab|episodio|serie/i) await expect(page.getByRole('tab', { name: 'Series Lab' })).toHaveAttribute('aria-selected', 'true') await expect(page.getByLabel('Series Lab workspace')).toContainText(title) + const config = await liveConfig(request, test.info()) + const library = await json(request, `/api/v1/series/library?workspace=${encodeURIComponent(String(config.execution_workspace))}`) as SeriesLibrary + const series = Object.values(library.seriesById).find(item => Object.values(item.episodesById).some(episode => episode.title === title)) + const episode = Object.values(series?.episodesById || {}).find(item => item.title === title) + expect(series?.characters.length).toBeGreaterThan(0) + expect(series?.locations.length).toBeGreaterThan(0) + expect(episode?.premise).toMatch(/\S/) + expect(episode?.outline.beats.length).toBeGreaterThan(0) + expect(episode?.shots.length).toBeGreaterThanOrEqual(4) + await testInfo.attach('persisted-series-library.json', { body: JSON.stringify(library, null, 2), contentType: 'application/json' }) await attachEvidence(page, request, testInfo, transcript) }) test('wizard: injected executor failure remains observable and retryable', async ({ page, request }, testInfo) => { test.skip(scenario !== 'failure', `scenario=${scenario}`) - const config = await json(request, '/api/v1/system-config') as SystemConfig + const config = await liveConfig(request, test.info()) const workspace = String(config.execution_workspace) const beforeFailure = await rootTaskIds(request, workspace) const transcript = await ask(page, @@ -440,8 +626,8 @@ test('wizard: injected executor failure remains observable and retryable', async if (await activityButton.getAttribute('aria-expanded') !== 'true') await activityButton.click() await expect(page.getByTitle('Injected simulated audio executor failure', { exact: true })).toBeVisible() if (await activityButton.getAttribute('aria-expanded') === 'true') await activityButton.click() - await page.getByTitle('Ask to the Wizard about the app or current task queue').click() - await expect(page.getByRole('dialog', { name: 'Ask to the Wizard' })).toBeVisible() + if (!await wizardPanel(page).isVisible()) await page.getByRole('button', { name: 'Expand Ask to the Wizard' }).click() + await expect(wizardPanel(page)).toBeVisible() const beforeRetry = await rootTaskIds(request, workspace) const retryTranscript = await ask( page, @@ -458,7 +644,7 @@ test('wizard: injected executor failure remains observable and retryable', async test('wizard: a queued simulated generation can be cancelled from the visible Activity UI', async ({ page, request }, testInfo) => { test.skip(scenario !== 'cancel', `scenario=${scenario}`) - const config = await json(request, '/api/v1/system-config') as SystemConfig + const config = await liveConfig(request, test.info()) expect(config.execution_mode).toBe('simulate') expect(Number(config.execution_simulation_step_delay || 0)).toBeGreaterThanOrEqual(0.5) const workspace = String(config.execution_workspace) @@ -478,21 +664,19 @@ test('wizard: a queued simulated generation can be cancelled from the visible Ac await attachEvidence(page, request, testInfo, transcript) }) -test('wizard: workspace switching refreshes its visible and server context', async ({ page, request }, testInfo) => { +test('wizard: workspace switching refreshes browser context while preserving global selection', async ({ page, request }, testInfo) => { test.skip(scenario !== 'workspace', `scenario=${scenario}`) - const config = await json(request, '/api/v1/system-config') as SystemConfig + const config = await liveConfig(request, test.info()) const primary = String(config.execution_workspace) - const secondary = `e2e_wizard_alt_${Date.now()}` + const secondary = `${primary}_alt` const first = await ask( page, `Crea el workspace exacto "${secondary}" si no existe y cámbiate a él. No generes nada.`, ) - await expect(page.getByRole('dialog', { name: 'Ask to the Wizard' })).toContainText(`Workspace: ${secondary}`) - expect((await json(request, '/api/v1/workspaces') as { active: string }).active).toBe(secondary) + await expect(wizardPanel(page)).toContainText(`Workspace: ${secondary}`) + expect(isolationByPage.get(page)?.selected()).toBe(secondary) const second = await ask(page, `Vuelve ahora al workspace exacto "${primary}". No generes nada.`) - await expect(page.getByRole('dialog', { name: 'Ask to the Wizard' })).toContainText(`Workspace: ${primary}`) - expect((await json(request, '/api/v1/workspaces') as { active: string }).active).toBe(primary) + await expect(wizardPanel(page)).toContainText(`Workspace: ${primary}`) + expect(isolationByPage.get(page)?.selected()).toBe(primary) await attachEvidence(page, request, testInfo, `${first}\n\n--- SWITCH BACK ---\n\n${second}`) - const removed = await request.delete(`/api/v1/workspaces/${encodeURIComponent(secondary)}`) - expect(removed.ok(), await removed.text()).toBeTruthy() }) diff --git a/ui/e2e/playwright.live.config.ts b/ui/e2e/playwright.live.config.ts index dd7d38f8..b7f68d3a 100644 --- a/ui/e2e/playwright.live.config.ts +++ b/ui/e2e/playwright.live.config.ts @@ -1,26 +1,30 @@ import { defineConfig } from '@playwright/test' +import path from 'node:path' -const baseURL = process.env.HOCUSPOCUS_BASE_URL || 'http://127.0.0.1:7860' +const baseURL = process.env.HOCUSPOCUS_BASE_URL +if (!baseURL) throw new Error('Set HOCUSPOCUS_BASE_URL to the running Pinokio URL, or use scripts/run_wizard_acceptance.py --base-url.') +const root = process.env.HOCUSPOCUS_E2E_ARTIFACT_DIR || path.resolve('test-results/wizard-live') +const executablePath = process.env.HOCUSPOCUS_E2E_CHROMIUM_EXECUTABLE +const scenario = process.env.HOCUSPOCUS_E2E_SCENARIO || 'smoke' +const filters: Record = { + smoke: /wizard: Studio/, studio: /wizard: Studio/, language: /wizard: UI locale/, + 'music-video': /wizard: vocal/, 'music-video-new': /wizard: one-turn/, + comic: /wizard: multi-page/, series: /wizard: Series Lab/, failure: /wizard: injected/, + cancel: /wizard: a queued/, workspace: /wizard: workspace switching/, + 'wizard-media': /wizard: Ask to the Wizard real image and music outputs/, + full: /wizard: (Studio|UI locale|vocal|multi-page|Series Lab)/, + 'app-tour': /app: feature tour/, 'app-generate': /app: real/, app: /app:/, +} +if (!filters[scenario]) throw new Error(`Unknown live acceptance scenario: ${scenario}`) export default defineConfig({ - testDir: './live-specs', - fullyParallel: false, - workers: 1, - retries: 0, - timeout: 30 * 60_000, - expect: { timeout: 30_000 }, - outputDir: '../test-results/wizard-live', - reporter: [ - ['list'], - ['html', { open: 'never', outputFolder: '../playwright-report/wizard-live' }], - ['json', { outputFile: '../test-results/wizard-live/results.json' }], - ], + testDir: './live-specs', fullyParallel: false, workers: 1, retries: 0, + timeout: 30 * 60_000, expect: { timeout: 30_000 }, grep: filters[scenario], + outputDir: path.join(root, 'raw'), + reporter: [['list'], ['html', { open: 'never', outputFolder: path.join(root, 'report') }], ['json', { outputFile: path.join(root, 'results.json') }]], use: { - baseURL, - locale: 'en-US', - viewport: { width: 1440, height: 900 }, - trace: 'on', - screenshot: 'on', - video: 'retain-on-failure', + baseURL, locale: 'en-US', viewport: { width: 1440, height: 1000 }, + trace: 'on', screenshot: 'on', video: 'retain-on-failure', + launchOptions: executablePath ? { executablePath, args: ['--no-sandbox', '--ignore-gpu-blocklist'] } : undefined, }, }) diff --git a/ui/scripts/export-acceptance-capabilities.mts b/ui/scripts/export-acceptance-capabilities.mts new file mode 100644 index 00000000..a968b5b0 --- /dev/null +++ b/ui/scripts/export-acceptance-capabilities.mts @@ -0,0 +1,10 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import {pathToFileURL} from 'node:url' +const output=process.argv[2] +if(!output)throw Error('Pass a JSON output path') +const source=process.env.HOCUSPOCUS_E2E_UI_SOURCE || path.resolve('.') +const {listCapabilities}=await import(pathToFileURL(path.join(source,'src/features/agent/capabilityRegistry.ts')).href) +const capabilities=listCapabilities().map(c=>({name:c.name,title:c.title,description:c.description,useWhen:c.useWhen,parameters:c.parameters,inputSchema:c.inputSchema,risk:c.risk,presentation:c.presentation})) +await fs.writeFile(output,JSON.stringify({source,capabilities},null,2)) +console.log(`${capabilities.length} registered capabilities exported to ${output}`) diff --git a/ui/src/components/MainContent/TabFilter.tsx b/ui/src/components/MainContent/TabFilter.tsx index 91e224b5..7e3c8ec0 100644 --- a/ui/src/components/MainContent/TabFilter.tsx +++ b/ui/src/components/MainContent/TabFilter.tsx @@ -307,8 +307,8 @@ export function TabFilter() { return (