From b7aa3122a963d4277fa2b5c8c3cc2d5c62149d0b Mon Sep 17 00:00:00 2001 From: Qunmi Date: Mon, 19 Oct 2026 14:14:04 +0800 Subject: [PATCH 1/3] feat(control-plane): add shared Quick Test exploration workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 10 +- README.zh-CN.md | 10 +- SPEC.md | 34 +- docs/cli-reference.md | 4 +- docs/getting-started.md | 14 +- docs/getting-started.zh-CN.md | 14 +- frontend/control-plane/SPEC.md | 90 ++- .../src/api/controlPlaneClient.test.ts | 2 +- .../src/api/controlPlaneClient.ts | 71 +- .../src/api/quickDiagnostics.test.ts | 61 ++ .../control-plane/src/api/quickTest.test.ts | 69 ++ frontend/control-plane/src/api/types.ts | 40 +- .../src/app/ControlPlaneApp.test.tsx | 10 + .../control-plane/src/app/ControlPlaneApp.tsx | 44 +- .../src/app/EntryApp.integration.test.tsx | 176 +++++ .../control-plane/src/app/EntryApp.test.tsx | 76 ++ frontend/control-plane/src/app/EntryApp.tsx | 100 +++ frontend/control-plane/src/app/entry.css | 14 + .../src/app/shell/ControlPlaneShell.test.tsx | 11 + .../src/app/shell/ControlPlaneShell.tsx | 18 +- .../control-plane/src/app/shell/navigation.ts | 7 +- .../src/features/devices/DevicesPage.tsx | 8 +- .../components/LiveEvidencePanel.test.tsx | 29 +- .../devices/components/LiveEvidencePanel.tsx | 10 +- .../components/PreflightStatus.test.tsx | 24 +- .../devices/components/PreflightStatus.tsx | 60 +- .../components/PrerequisiteList.test.tsx | 12 + .../devices/components/PrerequisiteList.tsx | 4 +- .../devices/components/RunTimeline.test.tsx | 14 + .../devices/components/RunTimeline.tsx | 16 +- .../devices/hooks/useDeviceWorkspace.test.ts | 60 ++ .../devices/hooks/useDeviceWorkspace.ts | 62 +- .../devices/hooks/useRunStream.test.ts | 28 + .../quick-test/AndroidSelection.test.tsx | 510 +++++++++++++ .../QuickTestPage.configError.test.tsx | 79 ++ .../QuickTestPage.connection.test.tsx | 211 ++++++ .../quick-test/QuickTestPage.test.tsx | 308 ++++++++ .../src/features/quick-test/QuickTestPage.tsx | 78 ++ .../src/features/quick-test/quick-test.css | 18 + .../features/quick-test/useQuickTest.test.ts | 128 ++++ .../src/features/quick-test/useQuickTest.ts | 197 +++++ frontend/control-plane/src/main.tsx | 4 +- .../control-plane/src/shared/ContentTabs.tsx | 7 +- frontend/control-plane/src/styles/devices.css | 3 + .../test/quick-test-android-config-error.json | 1 + .../src/test/quickTestFixtures.ts | 22 + fsq_agent/adapters/control_plane/_evidence.py | 30 +- .../adapters/control_plane/_execution.py | 6 +- .../adapters/control_plane/_quick_test.py | 87 +++ .../adapters/control_plane/_readiness.py | 53 +- fsq_agent/adapters/control_plane/_server.py | 55 +- fsq_agent/adapters/control_plane/_state.py | 27 +- fsq_agent/agent_engine/SPEC.md | 2 +- fsq_agent/ai_services/SPEC.md | 26 +- fsq_agent/ai_services/__init__.py | 6 +- fsq_agent/ai_services/_factory.py | 5 + fsq_agent/ai_services/_quick_test.py | 156 ++++ fsq_agent/application/SPEC.md | 38 +- fsq_agent/application/__init__.py | 27 +- fsq_agent/application/_quick_test.py | 328 +++++++++ fsq_agent/application/contracts/__init__.py | 21 +- fsq_agent/application/contracts/doctor.py | 31 +- fsq_agent/application/contracts/shared.py | 10 + fsq_agent/application/contracts/workspace.py | 82 ++- fsq_agent/application/doctor.py | 87 ++- fsq_agent/application/workspace.py | 15 + fsq_agent/cli/SPEC.md | 2 +- fsq_agent/config/SPEC.md | 2 + fsq_agent/config/__init__.py | 2 + fsq_agent/config/_loader.py | 2 +- fsq_agent/config/_user_provider.py | 4 +- fsq_agent/config/_workspace.py | 72 +- fsq_agent/control_plane/SPEC.md | 37 +- fsq_agent/environments/SPEC.md | 8 + fsq_agent/environments/_applications.py | 56 ++ fsq_agent/environments/_service.py | 12 +- fsq_agent/environments/providers/SPEC.md | 5 +- .../environments/providers/_adb_transport.py | 31 + fsq_agent/environments/providers/_android.py | 14 +- .../environments/providers/_applications.py | 178 +++++ fsq_agent/environments/providers/_macos.py | 13 +- fsq_agent/execution/SPEC.md | 7 + fsq_agent/execution/dynamic.py | 97 ++- fsq_agent/execution/runs.py | 30 +- fsq_agent/models/SPEC.md | 2 + fsq_agent/models/__init__.py | 4 + fsq_agent/models/_application_lookup.py | 41 ++ fsq_agent/models/_platform_runtime.py | 2 +- fsq_agent/providers/SPEC.md | 4 +- tests/test_agent_engine_inference_paths.py | 98 ++- tests/test_application_doctor.py | 7 + tests/test_control_plane.py | 2 +- tests/test_control_plane_history.py | 173 ++++- tests/test_dynamic_execution.py | 298 +++++++- tests/test_quick_test_application.py | 694 ++++++++++++++++++ tests/test_quick_test_discovery.py | 385 ++++++++++ tests/test_quick_test_intent.py | 241 ++++++ tests/test_quick_test_shared_diagnosis.py | 262 +++++++ tests/test_quick_test_transport.py | 242 ++++++ 99 files changed, 6640 insertions(+), 247 deletions(-) create mode 100644 frontend/control-plane/src/api/quickDiagnostics.test.ts create mode 100644 frontend/control-plane/src/api/quickTest.test.ts create mode 100644 frontend/control-plane/src/app/EntryApp.integration.test.tsx create mode 100644 frontend/control-plane/src/app/EntryApp.test.tsx create mode 100644 frontend/control-plane/src/app/EntryApp.tsx create mode 100644 frontend/control-plane/src/app/entry.css create mode 100644 frontend/control-plane/src/features/quick-test/AndroidSelection.test.tsx create mode 100644 frontend/control-plane/src/features/quick-test/QuickTestPage.configError.test.tsx create mode 100644 frontend/control-plane/src/features/quick-test/QuickTestPage.connection.test.tsx create mode 100644 frontend/control-plane/src/features/quick-test/QuickTestPage.test.tsx create mode 100644 frontend/control-plane/src/features/quick-test/QuickTestPage.tsx create mode 100644 frontend/control-plane/src/features/quick-test/quick-test.css create mode 100644 frontend/control-plane/src/features/quick-test/useQuickTest.test.ts create mode 100644 frontend/control-plane/src/features/quick-test/useQuickTest.ts create mode 100644 frontend/control-plane/src/test/quick-test-android-config-error.json create mode 100644 frontend/control-plane/src/test/quickTestFixtures.ts create mode 100644 fsq_agent/adapters/control_plane/_quick_test.py create mode 100644 fsq_agent/ai_services/_quick_test.py create mode 100644 fsq_agent/application/_quick_test.py create mode 100644 fsq_agent/environments/_applications.py create mode 100644 fsq_agent/environments/providers/_applications.py create mode 100644 fsq_agent/models/_application_lookup.py create mode 100644 tests/test_quick_test_application.py create mode 100644 tests/test_quick_test_discovery.py create mode 100644 tests/test_quick_test_intent.py create mode 100644 tests/test_quick_test_shared_diagnosis.py create mode 100644 tests/test_quick_test_transport.py diff --git a/README.md b/README.md index b50d467e..0a7566c3 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,12 @@ python -m pip install --upgrade fsq-agent fsq ui ``` -In **Settings**, configure the FSQ LLM Provider and explicitly select a model. +Choose **Quick Test** for exploration using the automatic `~/.fsq/quick-test/` Workspace, or **FSQ Case Manager** for Workspace/Case management, history, and Strict Replay. Both share **Settings**, where you configure the FSQ LLM Provider and explicitly select a model. -**First test.** -1. Create or select a Workspace, and configure its platform and target as needed. -2. In **Test Runner**, select the exact target. -3. Describe a verifiable result, review the test setup, and start the test. Review the result first and, if FSQ returns a Case, review it unchanged before separately requesting any replay. +1. Select the platform and, where applicable, the device or browser. +2. Describe what to do and verify. For Web, provide a URL or a clear search goal, for example: `Search FSQ on GitHub and find its official website.` You do not need to know the result URL beforehand. +3. Select **Start Quick Test**. The model identifies the application; FSQ validates the resolved target and environment before exploration. Unresolved intent, zero/multiple lookup results, and preparation failures stop the request without a follow-up dialogue. Target availability does not prove the model understood the application correctly. +4. Review the result and evidence using **Open report**, or choose **New test**. Quick Test does not save Cases; in Case Manager, review any generated Case unchanged before separately requesting replay. ![FSQ Test Runner for describing a test goal](docs/media/01-describe-goal.png) diff --git a/README.zh-CN.md b/README.zh-CN.md index 72a4c2ab..d0ea93f6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,12 +67,12 @@ python -m pip install --upgrade fsq-agent fsq ui ``` -在 **Settings** 中配置 FSQ LLM Provider,并显式选择模型。 +选择 **Quick Test**,自动使用 `~/.fsq/quick-test/` Workspace 进行探索;或选择 **FSQ Case Manager** 管理 Workspace、Case、历史运行及 Strict Replay。两个入口共用 **Settings**,在其中配置 FSQ LLM Provider 并显式选择模型。 -**首次测试。** -1. 创建或选择 Workspace,并按需配置其平台和目标。 -2. 在 **Test Runner** 中选择准确目标。 -3. 描述可验证的结果,审查测试设置,然后开始测试。先审查结果;如果 FSQ 返回了 Case,再原样审查它,然后另行决定是否请求重放。 +1. 选择平台,以及适用的设备或浏览器。 +2. 描述要执行的操作和预期结果。Web 可以提供网址,也可以描述明确的搜索目标,例如:`搜索 FSQ github,并且帮我找到 FSQ 的官网地址。` 无需预先知道要找到的网址。 +3. 点击 **Start Quick Test**。模型识别应用,FSQ 验证解析出的目标和环境后才开始探索。意图不明确、目标查询零匹配或多匹配、准备失败时直接终止,不补问;目标可运行不等于已证明模型理解正确。 +4. 通过 **Open report** 审查结果和证据,或选择 **New test**。Quick Test 不保存 Case;在 Case Manager 中应先原样审查生成的 Case,再另行决定是否重放。 ![用于描述测试目标的 FSQ Test Runner](docs/media/01-describe-goal.png) diff --git a/SPEC.md b/SPEC.md index de1137d8..8a7b5b64 100644 --- a/SPEC.md +++ b/SPEC.md @@ -52,15 +52,15 @@ Goal-based Case creation uses pre-plan as the input-understanding boundary befor Existing-Case testing parses the Case through FSQ rather than treating YAML as untyped planning text. Suggestion-enabled testing first completes the same single deterministic execution as ordinary Case testing, then gives the parsed Case and bounded persisted execution facts to a read-only AI analysis that cannot invoke Harness, Driver, Core capabilities, or other UI actions. The analysis preserves the authoritative execution status and facts, keeps source steps immutable, and writes suggestions and any candidate Case only inside that Run directory. -`DynamicExecutionService` owns dynamic Run allocation, source snapshots, authoritative evidence context, frozen execution conclusions, derived processing, and metadata transitions. It supplies a Models-owned `RunExecutionContext` and shared evidence sink to `FsqAgent.run_in_context`; Agent performs planning, execution, verification, and business event emission through neutral runtimes and returns `DynamicAgentOutcome` without importing Execution, allocating/updating Run metadata, or generating reports. Execution freezes execution and verification facts before report generation and optional recording, then finalizes processing and cancellation/error paths without creating another Run. Processing failures cannot overwrite the frozen execution conclusion. +`DynamicExecutionService` owns dynamic Run allocation, source snapshots, authoritative evidence context, frozen execution conclusions, derived processing, and metadata transitions. It supplies a Models-owned `RunExecutionContext` and shared evidence sink to `FsqAgent.run_in_context`; Agent performs planning, execution, verification, and business event emission through neutral runtimes and returns `DynamicAgentOutcome` without importing Execution, allocating/updating Run metadata, or generating reports. Execution applies explicitly requested evidence requirements and freezes execution and verification facts before report generation and optional recording, then finalizes processing and cancellation/error paths without creating another Run. Processing failures cannot overwrite the frozen execution conclusion. ## Model And Agent Boundary `agent_engine` owns independently reusable model/provider/agent-execution protocols and neutral input, output, tool, event, filter, and error contracts. Its private backends use the pinned OpenAI Python client for Responses requests and the pinned Google GenAI Python client for Gemini Developer API Interactions requests. Concrete backend objects, vendor message shapes, third-party exceptions, and concrete constructor injection do not cross into FSQ business code. -All six inference paths use this boundary for OpenAI, Azure OpenAI, DeepSeek, Google Gemini, Kimi, and GitHub Copilot: pre-plan, main execution, and verification call `AgentEngine.run`; visual assertions, connection testing, and Case suggestions call tool-free `Model.complete`. A sole local tool dispatcher and continuation loop is shared across backends; each private backend owns round trips, protocol conversion, and opaque history, not another execution loop. Model-paired engine selection remains inside `agent_engine`. Direct requests do not enter the agent loop, and the loop does not call public tool-free `Model.complete` or add semantic retries. +All inference paths use this boundary for OpenAI, Azure OpenAI, DeepSeek, Google Gemini, Kimi, and GitHub Copilot: pre-plan, main execution, and verification call `AgentEngine.run`; visual assertions, connection testing, Case suggestions, and Quick Test intent analysis call tool-free `Model.complete`. A sole local tool dispatcher and continuation loop is shared across backends; each private backend owns round trips, protocol conversion, and opaque history, not another execution loop. Model-paired engine selection remains inside `agent_engine`. Direct requests do not enter the agent loop, and the loop does not call public tool-free `Model.complete` or add semantic retries. -Both neutral request types carry validated `reasoning_effort` with values `low`, `mid`, or `high` and an omitted-field default of `mid`. Every backend request, including continuation, explicitly sends the mapped native effort. Coding Agent and AI Services forward the resolved platform effort across the five business paths; connection testing independently requests `low`. Agent Engine owns private numeric-version mappings, while Providers owns compatible discovery filters and identifies Azure's opaque deployment-name context without looking up an underlying model. Invalid effort fails before inference or tool effects; unsupported native settings follow safe error handling without effort negotiation, hidden model replacement, or a repair request. +Both neutral request types carry validated `reasoning_effort` with values `low`, `mid`, or `high` and an omitted-field default of `mid`. Every backend request, including continuation, explicitly sends the mapped native effort. Coding Agent and AI Services forward the resolved platform effort across business inference paths; connection testing independently requests `low`. Agent Engine owns private numeric-version mappings, while Providers owns compatible discovery filters and identifies Azure's opaque deployment-name context without looking up an underlying model. Invalid effort fails before inference or tool effects; unsupported native settings follow safe error handling without effort negotiation, hidden model replacement, or a repair request. Both engine entry points share private response validation and caller-owned output-contract parsing inside `agent_engine`. Optional `ModelRequest.output` and `ModelResult.parsed_output` carry the same generic output type through neutral model and Provider session calls while retaining existing text, usage, completion information, and positional argument order. A constrained single call sends the backend-native schema and returns the extracted original text plus the parser's value only after protocol validation and parsing succeed. Unconstrained calls, including connection testing, retain plain-text behavior without a JSON-schema format. The engine does not depend on FSQ business models or Pydantic, and Providers does not interpret schemas or parse results. @@ -70,12 +70,30 @@ Known incomplete, failed, or refused responses produce safe neutral `EngineError The supported engine surface consists of bound function tools, caller-owned output contracts, streaming and non-streaming execution, context filtering, and safe tracing export. Runtime and normal test dependencies do not include `openai-agents`; no SDK fallback, complete SDK copy, or runtime reference-checkout dependency is used. -Gemini direct and Agent inference explicitly disable Interactions storage with `store=false` and use complete local continuation without server-managed conversation ids. Required opaque provider state stays private; context filtering exposes only neutral tool-output entries. The actual pinned clients over controlled HTTP/SSE transport verify protocol compatibility, built-in FSQ schemas, and all six inference paths; SDK doubles alone are not compatibility evidence. +Gemini direct and Agent inference explicitly disable Interactions storage with `store=false` and use complete local continuation without server-managed conversation ids. Required opaque provider state stays private; context filtering exposes only neutral tool-output entries. The actual pinned clients over controlled HTTP/SSE transport verify protocol compatibility, built-in FSQ schemas, and all inference paths; SDK doubles alone are not compatibility evidence. -`providers` owns FSQ supplier authentication, token refresh/cache policy, model discovery/filtering, configured access, and connection testing. It supplies explicit connection values and owns neutral provider scopes, not concrete inference clients. `ai_services` owns assertion/suggestion prompts, input preparation, private model-facing schemas and parsers, conversion of parsed objects to public business results, factories, and suggestion readiness. `adapters.coding_agent` binds FSQ policies and tools to neutral execution and maps events/results back into existing business contracts. Core receives evaluators through its existing injected protocol. +`providers` owns FSQ supplier authentication, token refresh/cache policy, model discovery/filtering, configured access, and connection testing. It supplies explicit connection values and owns neutral provider scopes, not concrete inference clients. `ai_services` owns assertion, suggestion, and Quick Test intent prompts, input preparation, private model-facing schemas and parsers, conversion of parsed objects to public business results, factories, and suggestion readiness. It does not discover installed applications or authorize execution. `adapters.coding_agent` binds FSQ policies and tools to neutral execution and maps events/results back into existing business contracts. Core receives evaluators through its existing injected protocol. The engine has no dependency on FSQ business packages or storage. Public inference contracts do not include mandatory authentication methods, GPT-specific high-level model settings, server-managed conversation identifiers, or vendor transcript passthrough. FSQ business runtime names are SDK-neutral: the Coding Agent factory returns the public protocol implemented by `DefaultCodingAgentRuntime`, and Config exposes `Settings.agent_runtime` using Models-owned `AgentRuntimeSettings` and `AgentPromptConfig`. Genuine OpenAI client, backend, dependency, supplier, endpoint, authentication-file, and tracing names retain their actual identities. +## Quick Test And Case Manager + +`fsq ui` serves one Control Plane application with two browser entry choices: Quick Test for goal-based exploration without Workspace setup, and FSQ Case Manager for the complete Workspace, Case, Test Runner, history, and Provider workflows. Existing Run report links open Case Manager reports directly. Quick Test uses the same global Provider settings, platform presets, dynamic Execution service, live evidence, and report contracts; it does not introduce a second execution engine or a public CLI test command. + +Quick Test supports Android, Web, Windows, and macOS under their existing host/runtime restrictions. Users explicitly select a platform and the applicable device or browser, then supply one natural-language goal. Android alone permits automatic selection of the sole available device when no selection or explicit clearing intent exists, under the selection lifecycle in `frontend/control-plane/SPEC.md`; this never replaces an existing device identity or bypasses validation or explicit Start. Workspace and Explore/Strict selection are absent; Quick Test is always Explore. Its sidebar contains Quick Test and bottom Settings only. Strict Replay and Case management remain in Case Manager. + +Application coordinates tool-free intent analysis through AI Services and concrete target validation through Environments before normal execution preflight. The model interprets the application named in the current prompt and may infer its native package, bundle, or executable identity. Web accepts either a valid explicit HTTP(S) starting address or a clear search/discovery goal that does not supply an address. The prompt must provide enough intent to infer test actions and a checkable outcome; the website address to be discovered is a result, not a required input. Input sufficiency is semantic, not a minimum character count. Unresolved or ambiguous intent, zero or multiple matches within a required target lookup, invalid supplied addresses, discovery failure, and model failure terminate the request with distinct safe errors before application launch or UI actions. There is no clarification dialogue, candidate picker, automatic target substitution, or reuse of the last application's identity to complete an ambiguous prompt. + +URL-free Web search uses the selected browser and its existing blank-page startup and absolute navigation capabilities. Application clears the current and frozen Web base URL, without inventing a user-supplied address, guessing the website to be discovered, configuring a search provider, or introducing another search service. The existing Agent plans and executes the search route, respecting any route or site named in the goal. A supplied malformed or unsupported navigation address is an error, not permission to use this address-free path. + +These URL-free Quick Test searches carry an explicit execution evidence requirement. Before freezing a successful result, Execution requires actual current-Run Web capability activity and usable browser observations of a nonblank HTTP(S) page. Model text, a claimed URL, synthetic planning/verifier steps, and blank-browser startup alone do not satisfy it. Missing supporting browser facts prevent a success conclusion even when the model reports success. This is a necessary evidence gate, not proof that a discovered site is official; goal verification still evaluates the observed page/link sources. Other dynamic tasks, explicit-URL Quick Tests, and Strict Replay retain their existing completion policies. + +Platform checks establish that the explicit or model-inferred target exists and satisfies runtime prerequisites. They do not establish that the model understood the intended application correctly, and Quick Test does not enumerate every same-named installed application to prove semantic uniqueness. An inferred native identity can scope concrete lookup without a separate local display-name-to-identity mapping. Model output alone never establishes installation, readiness, or test success; actual lookup and preflight remain mandatory. + +All Quick Tests for one user configuration root use the registered `quick-test` Workspace at its `quick-test/` child, normally `~/.fsq/quick-test/`. Application prepares only the selected platform on a valid start through existing Config persistence, verifies the exact name/root pair, and preserves other Workspaces, Provider state, private env, Cases, and history. No placeholder application configuration or alternate-root fallback exists. Each execution freezes its validated target and settings. The dedicated Workspace remains visible to Case Manager. + +The two entries share the existing single-active-task boundary per Control Plane service, including Quick Test preparation. Browser navigation, refresh, or closure does not cancel a server task; returning to Quick Test recovers its active task without replaying the start request. Service restart does not resume execution. Quick Test presents truthful current results, evidence, and the existing persistent report link, but no Save Case action or separate history-management page. Run-local recordings retain existing Explore semantics and are not automatically published. + ## Runtime Configuration Defaults Except for user-level Provider commands, static Case formatting, and creation of an unregistered Workspace, the exact CLI current directory is a registered workspace root using the canonical `.fsq/config/config..yaml`, `.fsq/runs//`, `cases//`, and `knowledge//` layout. CLI does not create or accept `.fsq-agent-workspace` markers, search parents, or auto-initialize. For a new name, `fsq init` treats the current directory as the selected directory: an empty directory becomes the Workspace root, while a non-empty directory receives a new `/` child. For an existing registered name, initialization uses its stored root independently of the process current directory. Workspace-scoped CLI commands require the exact registered root, and platform execution operations require the selected platform. Control Plane uses the same Application and Config-owned root-selection and registry rules while retaining explicit browser workspace selection independent of its startup directory. @@ -223,13 +241,13 @@ The bundled `assets/getting-started.web.fsq.yaml` remains a canonical provider-f | config | fsq_agent/config/SPEC.md | Loads and validates env/YAML runtime, provider, harness/driver/platform-tool, tracing, execution post-action delay, strict case lifecycle hook settings, strict replay secret, agent context, AgentTool output, CommonTool secret, and workspace configuration. | | providers | fsq_agent/providers/SPEC.md | Owns OpenAI/Azure/DeepSeek/Gemini/Kimi/Copilot supplier authentication, token and model-selection policy, connection testing, and configured neutral model access. | | agent_engine | fsq_agent/agent_engine/SPEC.md | Owns reusable model/provider/agent protocols, neutral inference contracts, private Responses/Interactions backends, shared local agent execution, and safe tracing export. | -| ai_services | fsq_agent/ai_services/SPEC.md | Owns visual assertion and completed-Case suggestion business services over neutral model calls. | +| ai_services | fsq_agent/ai_services/SPEC.md | Owns visual assertion, completed-Case suggestion, and Quick Test intent analysis over neutral model calls. | | tools | fsq_agent/tools/SPEC.md | Provides dynamic-only AgentTool providers, scoped file/artifact helpers, and neutral agent-engine tool bindings. | | observation | fsq_agent/observation/SPEC.md | Persists run event timelines; screenshots, UI trees, and other observations are represented by platform evidence artifacts or AgentTool artifact refs. | | knowledge | fsq_agent/knowledge/SPEC.md | Loads project-specific application knowledge and task-referenced knowledge assets. | | case_dsl | fsq_agent/case_dsl/SPEC.md | Owns canonical Case loading, static validation, normalization, YAML serialization, and deterministic-step adaptation. | | fsq | fsq_agent/fsq/SPEC.md | Preserves the documented legacy Case DSL public import surface by forwarding to canonical `case_dsl` objects. | -| environments | fsq_agent/environments/SPEC.md | Owns host/runtime support, read-only readiness checks, and Web executable discovery through platform providers. | +| environments | fsq_agent/environments/SPEC.md | Owns host/runtime support, read-only readiness, bounded application discovery, and Web executable discovery through platform providers. | | skills | fsq_agent/skills/SPEC.md | Loads complete configured automation skill instruction bundles and skips or fails broken bundles according to requiredness. | | report | fsq_agent/report/SPEC.md | Projects persisted facts into shared public reports, deterministic comparisons, JSON/JUnit/HTML/bundle exports, and compatible internal reports. | | core | fsq_agent/core/SPEC.md | Navigates platform-neutral Core ownership across Runner, Evidence, Interfaces, current capability/runtime services, and compatibility composition. | @@ -240,7 +258,7 @@ The bundled `assets/getting-started.web.fsq.yaml` remains a canonical provider-f | harnesses | fsq_agent/harnesses/SPEC.md | Owns concrete runtime gateways that combine CommonTools, injected drivers, runtime context, and evidence services. | | agent | fsq_agent/agent/SPEC.md | Orchestrates dynamic goal/reference execution through injected neutral runtimes, planning and verification policy, and safe event persistence. | | execution | fsq_agent/execution/SPEC.md | Owns Run lifecycle, frozen outcomes, provenance/lineage, dynamic/deterministic coordination, cancellation/teardown, and candidate Case recording. | -| application | fsq_agent/application/SPEC.md | Provides transport-neutral Workspace, Coding Agent Skill installation, Case, Run, Provider, and Environment operations through resource-owned modules, with shared Request, Result, Event, and Error contracts organized under `application/contracts`. | +| application | fsq_agent/application/SPEC.md | Provides transport-neutral Workspace and Quick Test preparation, Coding Agent Skill installation, Case, Run, Provider, and Environment operations through resource-owned modules, with shared Request, Result, Event, and Error contracts organized under `application/contracts`. | | adapters | fsq_agent/adapters/SPEC.md | Owns CLI, Control Plane, and coding-agent external protocol adaptation while depending inward on Application and public runtime contracts. | | adapters.coding_agent | fsq_agent/adapters/coding_agent/SPEC.md | Assembles FSQ requests/tools/policy above agent_engine and adapts neutral events/results to Agent runtime contracts. | | control_plane | fsq_agent/control_plane/SPEC.md | Adapts Application to local HTTP/SSE/static delivery, cancellation, persisted Run reports, evidence, and artifact/export delivery. | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a3e86344..5cb267b9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -21,10 +21,12 @@ FSQ installs `fsq` and the compatibility alias `fsq-agent`. Both invoke the same | `fsq case test --suggest` | Execute once, then analyze persisted facts. | Yes | | `fsq runs list/show/logs` | Query Workspace Run history without execution readiness. | Yes | | `fsq runs export RUN_ID` | Export JSON, JUnit, standalone HTML, or an evidence bundle. | Yes | -| `fsq ui` | Start the local Control Plane. | No | +| `fsq ui` | Start the local browser entry for Quick Test or FSQ Case Manager. | No | Except when creating an unregistered Workspace, Workspace commands use the exact current directory. They do not search ancestors or accept an alternate Workspace flag. +`fsq ui` can start from any directory. Choose **Quick Test** in the browser for exploration using the automatically prepared `~/.fsq/quick-test/` Workspace, or **FSQ Case Manager** for the complete existing workflows. Both use the same **Settings**. Entry selection does not itself start a test, and there is no separate Quick Test CLI command. See [the browser workflow](getting-started.md#open-the-control-plane). + Human output is for terminals. JSON produces one structured result. JSONL emits documented events and a terminal result where streaming applies. Invalid command requests use exit code 2; consume stable machine fields rather than human wording. Run export works without browser interaction or a Provider: diff --git a/docs/getting-started.md b/docs/getting-started.md index 4735bd3d..3798fc7e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -164,12 +164,12 @@ fsq providers status Alternatively run `fsq providers configure openai` for the official OpenAI API, `fsq providers configure deepseek` for the official DeepSeek Responses API, `fsq providers configure kimi` for the official China or Global Kimi API, or `fsq providers configure azure_openai` for an Azure deployment. Provider configuration is user-level, stored below `~/.fsq`, and shared with the local Control Plane. API-key Providers prompt privately for a key and offer eligible models; even a single model requires explicit selection. -For browser configuration, run `fsq ui` and open **Settings**: +For browser configuration, run `fsq ui`, choose **Quick Test** or **FSQ Case Manager**, and open their shared **Settings**: 1. Choose **Add configuration** or **Change provider**, then **OpenAI**, **DeepSeek**, **Google Gemini**, or **Kimi**. 2. For Kimi, explicitly choose **China** or **Global**. Enter an API key and select **Load models**. Select one offered model, then **Save changes**. 3. Select **Test connection** to send a minimal request using the saved configuration. -4. Return to **Home** to see the Provider summary, then open **Test Runner**, choose a Workspace/platform/target, and start an Explore task explicitly. +4. In **Quick Test**, return to the goal form and explicitly start a test. In **FSQ Case Manager**, return to **Home** to see the Provider summary, then open **Test Runner**, choose a Workspace/platform/target, and start an Explore task explicitly. OpenAI always uses `https://api.openai.com/v1/` and the Responses API. Its picker offers general-purpose GPT major version 5 or later, excluding mini, nano, Codex, embedding, audio, realtime, image, search, transcription, and TTS variants. An empty list cannot be saved. Changing the key invalidates the list and selection; reload before saving. Azure instead requires a resource endpoint, **deployment name** (not necessarily an OpenAI model id), and API key. @@ -219,6 +219,16 @@ fsq ui The installed frontend is served locally on `127.0.0.1:8879` by default. +Choose **Quick Test** for exploration without Workspace setup. Configure the shared Provider in **Settings**, select a platform and the applicable device/browser, and describe the application or search subject, actions, and expected result. Web supports both a supplied URL, such as `Open https://example.com and verify that its heading is Example Domain`, and a clear address-free search goal, such as `Search FSQ on GitHub and find its official website`. + +Address-free Web searches reuse the existing blank-page browser startup and Agent navigation. FSQ does not add a search-engine setting or assume the website found by a previous test. The Agent follows your goal to search and inspect pages; supplied malformed or unsupported navigation addresses are rejected rather than turned into searches. Before an address-free search can be reported as successful, it must have actual browser activity and usable HTTP(S) page observations. A model-only answer or blank-browser startup is insufficient. Page/link evidence still needs to support the requested result, including any claim that a discovered URL is official. + +**Start Quick Test** prepares a dedicated registered Workspace at `~/.fsq/quick-test/` and starts exploration only after input, concrete target, and runtime checks pass. All Quick Tests share this Workspace; each run freezes its own target. The model can infer the named application's package ID, bundle ID, or executable path; explicit identifiers can also be written in the same prompt. FSQ validates that resolved identity through the selected platform without requiring a separate localized application-name mapping. It does not exhaustively rule out all same-named installed apps, and availability does not prove that the model understood your intended application correctly. Insufficient or ambiguous intent, zero/multiple results within the resolved target lookup, and discovery or model failures stop the request without clarification or automatic retry. Correct the prompt or environment and start again explicitly. + +Results use the existing timeline and evidence views. **Open report** opens the persistent report, and **New test** returns to the prompt. Quick Test has no Save Case action or Strict Replay mode. Leaving or refreshing the page does not cancel an active server task, and returning recovers the active Quick Test while the same local service is running. Restarting the service does not resume execution. + +Choose **FSQ Case Manager** for the complete Workspace, Case, history, and strict-replay workflows described elsewhere in this guide. The dedicated Quick Test Workspace is visible there. Existing Run report links still open the corresponding report directly. + ## Next steps - Review [platform prerequisites](platform-prerequisites.md). diff --git a/docs/getting-started.zh-CN.md b/docs/getting-started.zh-CN.md index b9aacfce..e8ccadf2 100644 --- a/docs/getting-started.zh-CN.md +++ b/docs/getting-started.zh-CN.md @@ -141,12 +141,12 @@ fsq providers status 也可以运行 `fsq providers configure openai` 连接 OpenAI 官方 API,运行 `fsq providers configure deepseek` 连接 DeepSeek 官方 Responses API,运行 `fsq providers configure kimi` 连接中国区或国际区 Kimi 官方 API,或运行 `fsq providers configure azure_openai` 连接 Azure 部署。配置保存在用户级 `~/.fsq` 下,由 CLI 和 Control Plane 共享。API Key Provider 会隐藏 Key 输入并提供可选模型;即使只有一个模型也需要显式选择。 -浏览器配置流程:运行 `fsq ui`,打开 **Settings**。 +浏览器配置流程:运行 `fsq ui`,选择 **Quick Test** 或 **FSQ Case Manager**,打开两者共用的 **Settings**。 1. 点击 **Add configuration** 或 **Change provider**,选择 **OpenAI**、**DeepSeek**、**Google Gemini** 或 **Kimi**。 2. 使用 Kimi 时先显式选择 **China** 或 **Global**。输入 API Key,点击 **Load models**,选择模型,再点击 **Save changes**。 3. 点击 **Test connection**,使用已保存配置发送最小请求。 -4. 回到 **Home** 查看 Provider 摘要,再到 **Test Runner** 选择 Workspace、平台和目标,显式启动 Explore。 +4. 在 **Quick Test** 中回到目标表单并显式开始测试。在 **FSQ Case Manager** 中回到 **Home** 查看 Provider 摘要,再到 **Test Runner** 选择 Workspace、平台和目标,显式启动 Explore。 OpenAI 固定使用 `https://api.openai.com/v1/` 和 Responses API,不支持自定义端点。模型列表仅展示 GPT 主版本 5 或更高的通用模型,排除 mini、nano、Codex、embedding、audio、realtime、image、search、transcription、TTS 等变体。空列表不能保存;修改 Key 后必须重新加载并选择模型。Azure 则需要资源端点、**deployment name(部署名,不一定是 OpenAI model id)** 和 API Key。 @@ -195,6 +195,16 @@ fsq ui 安装后的前端默认在本地 `127.0.0.1:8879` 提供服务。 +选择 **Quick Test**,无需手动设置 Workspace 即可进行探索测试。先在 **Settings** 配置共享 Provider,选择平台及适用的设备或浏览器,再描述应用或搜索对象、操作和预期结果。Web 既支持提供网址,例如 `打开 https://example.com,确认页面标题为 Example Domain`,也支持不提供网址的明确搜索目标,例如 `搜索 FSQ github,并且帮我找到 FSQ 的官网地址`。 + +无网址的 Web 搜索复用现有空白页启动和 Agent 导航,不增加搜索引擎设置,也不沿用上一次测试的网站。Agent 按目标搜索并查看页面;用户提供的畸形或不支持的导航地址仍会报错,不会转成搜索来绕过校验。此类搜索必须有实际浏览器操作及可用的 HTTP(S) 页面观察证据,才能判定成功;仅凭模型回答或只启动空白浏览器不够。页面或链接证据仍需支持请求的结果,包括“所发现网址是官网”的判断。 + +**Start Quick Test** 会在 `~/.fsq/quick-test/` 准备专用注册 Workspace,只有输入、具体目标和运行环境检查全部通过才进入探索。所有 Quick Test 共用此 Workspace,每次运行冻结自己的目标。模型可根据应用名称推理 package ID、bundle ID 或可执行文件路径;也可在同一个 prompt 中直接写明这些标识。FSQ 通过所选平台验证解析出的标识,不要求额外取得本地化应用名称映射。它不会穷举排除所有同名应用,目标存在且可运行也不等于模型已正确理解你的意图。意图不足或不明确、具体目标查询零匹配或多匹配、发现或模型调用失败均直接终止,不补问、不自动重试。请自行修改 prompt 或修复环境后显式重新开始。 + +结果复用现有时间线和证据视图。**Open report** 打开持久报告,**New test** 回到目标输入。Quick Test 不提供保存 Case 或 Strict Replay。离开或刷新页面不会取消服务端活动任务;同一个本地服务仍在运行时,返回页面可恢复活动 Quick Test。重启服务不会续跑。 + +选择 **FSQ Case Manager**,使用本指南其他部分介绍的完整 Workspace、Case、历史和严格回放工作流。专用 Quick Test Workspace 也会显示在其中。现有 Run 报告链接仍直接打开对应报告。 + ## 下一步 - 阅读[平台前置条件](platform-prerequisites.md)。 diff --git a/frontend/control-plane/SPEC.md b/frontend/control-plane/SPEC.md index 8869402a..6fadefee 100644 --- a/frontend/control-plane/SPEC.md +++ b/frontend/control-plane/SPEC.md @@ -2,9 +2,9 @@ ## Purpose -Provide the production FSQ Control Plane browser entry. The entry owns a reusable application shell and left sidebar, a default Overview that separates global AI configuration from Workspace selection and direct Case actions, registry-backed multi-platform workspace navigation and management, a read-only workspace file browser, the workspace-aware Devices page for local execution, and the Config page for managing the single active local model provider. +Provide the production FSQ Control Plane browser entry with a Quick Test / FSQ Case Manager chooser. Quick Test owns a lightweight exploration-only presentation over shared execution components. Case Manager owns the complete Overview, registry-backed multi-platform workspace navigation and management, read-only workspace file browser, workspace-aware Devices page, and persisted Runs. Both entries use the reusable application shell and the same Config page for the single active local model provider. -The entry owns persisted Runs browsing/report presentation in addition to Workspace, Test Runner, and Provider configuration. It does not own backend validation, persistence/authentication, filesystem/target/Case truth, execution, report schemas, comparison algorithms, or file editing. Runnable Workspace selection remains in-memory and platform selection explicit; historical routing has separate validated scope and cannot authorize execution. +The entry does not own backend validation, persistence/authentication, filesystem/target/Case truth, execution, report schemas, comparison algorithms, or file editing. Case Manager runnable Workspace selection remains in-memory; Quick Test obtains its dedicated Workspace identity from the server rather than a chooser. Platform selection remains explicit in both entries; historical routing has separate validated scope and cannot authorize execution. ## Dependencies @@ -49,9 +49,53 @@ Workspace create/add/edit and Provider forms share field padding, label spacing, Provider choice/auth/result and Save YAML dialogs share the opaque white surface, 12px corners, restrained shadow, 24px desktop padding, heading/body/action spacing and shared button tiers. Narrow dialogs use 16px padding, viewport-bounded scrolling and fully reachable actions. Their existing validation, masking, pending, dismissal, focus and authorization behavior is preserved; host-native pickers and confirmation prompts retain native presentation. -### Application shell and sidebar +### Entry Choice And Quick Test -The entry renders one application-level `ControlPlaneShell` containing: +With no recognized entry or Run report address, the browser presents two equal-level entry cards: `Quick Test`, described as natural-language AI exploration without Workspace setup, and `FSQ Case Manager`, described as complete Workspace/Case management, exploration, strict replay, and Run inspection. The cards use existing tokens, controls, and responsive layout; they stack on narrow screens. Entry choice is browser navigation, not a terminal questionnaire. Existing `#runs` addresses bypass the chooser and retain their Case Manager history/report behavior. + +Quick Test uses the same shell with exactly two sidebar destinations: upper `Quick Test` and bottom `Settings`. A quiet title-bar return-to-entry link does not add a sidebar item. Settings reuses the existing global Provider workflow, including unsaved/pending/unknown-save guards. Case Manager keeps its complete navigation and independent explicit Workspace selection; selecting Quick Test's dedicated Workspace never overwrites that context. + +Quick Test setup retains the existing composer-beside-environment-checks layout. It omits the Workspace selector, Explore/Strict Replay cards, Case picker, and manual application-configuration forms. Platform choices come from supported-platform truth, not the dedicated Workspace's already configured platforms: Android, Web, Windows, and macOS remain visible under their existing host restrictions. Android exposes Device selection and Web exposes Browser selection. Desktop application identity is identified from the prompt and shown as a safe read-only summary when known. No package, bundle, path, URL, or application candidate picker is introduced. + +Quick Test Android automatically selects a device in the existing Device control only when a successful, valid, current authoritative context response contains exactly one eligible device and there is neither a selected identity nor explicit clearing intent. Eligibility uses the returned `selectable` and `status === 'ready'` facts for online, authorized devices, not full environment readiness. Zero or multiple eligible devices leave selection empty; offline or unauthorized entries are never auto-selected and do not disqualify a sole eligible device. An eligible empty selection can become selected on a current refresh from zero or multiple eligible devices to one; stale, invalid, or failed responses never select a device. + +Discovery and refresh preserve every existing nonempty Android selected identity, including unverified, disconnected, unavailable, or missing identities. They never clear an identity to select a fallback. The Device control retains that identity with truthful current status; configuration errors and unavailable or unchecked states retain existing diagnostic and Start-blocking semantics. Deliberate user reselection or clearing and the existing platform-change reset remain available. + +Quick Test feature state owns transient Android selection intent. Manually clearing the Device control keeps it empty through ordinary refresh, mounted Settings navigation, and `New test`, without immediate automatic refill. Automatic-selection eligibility resets only on an actual platform change that already resets selection or a fresh page mount, subject to server task recovery and locks. No persistent preference, control, or action is added. Asynchronous responses never override newer manual intent, a pending Start, an attached task, an unknown start outcome, or a frozen execution context; server-restored device identity remains authoritative. + +Automatic selection is not Ready: Start waits for current context facts matching the selected device and all existing Start conditions. It neither submits Start nor retries preparation or execution. Shared device validation, diagnostics, execution, task locks and recovery remain authoritative and unchanged; this selection lifecycle applies only to Quick Test Android, not other platforms or Case Manager. + +The environment region directly reuses the existing Devices `PreflightStatus` and `PrerequisiteList` components and their applicable platform presentation. It retains the existing status summary, prerequisite rows, passed-check disclosure, safe repair-command copy feedback, troubleshooting guide, checked timestamp, and recheck interaction rather than rendering a separate generic Quick Test checks card. Common prerequisite labels, status mapping, diagnostics, and styles have one shared owner. Quick Test does not add an environment-specific Open Settings button or a manual target-edit form; Settings remains available through the shared sidebar. + +Before an application is identified or the dedicated Workspace is prepared, the shared presentation supports an explicit pending context for only the dependent checks. It does not report these checks as failed or passed, convert unrelated `not_applicable` facts to failures, or disable the action needed to identify the application. Independent prerequisites retain their actual states, details, and actions. Once a concrete target is available, the same shared diagnostic presentation reflects actual target checks; Case Manager's existing configured-target behavior remains unchanged. + +The prompt asks for the application or search subject, intended actions, and expected outcome; technical identifiers may be written in that same prompt. Web accepts a valid explicit URL or a clear search/discovery goal without a URL; helper text describes both rather than requiring the user to know the website address being sought. Empty input has an immediate disabled-start reason; nonempty input is evaluated semantically by the server, not a length heuristic. A short actionable prompt is valid. An application name or bare search topic without a test objective cannot silently become a generic smoke test. + +Address-free Web searches use the same browser selector, goal input, start action, shared environment checks, workbench, and report view. There is no extra search mode, search-engine setting, URL form, or clarification dialogue. Invalid supplied addresses still produce address errors; an otherwise clear address-free search does not. The page displays the actual search context and backend outcome without inventing a starting or discovered website URL. A reported discovered address is a result, not browser readiness or proof of verified success. + +The API client consumes Quick Test context/preparation through the documented Control Plane transport. Context discovery can run before a Workspace or application is configured. Application-dependent checks say they await identification, not that the application is missing or the environment is ready. Start eligibility requires valid platform/device/browser selections, Provider and applicable basic runtime readiness, nonempty input, and no active/pending task; unresolved application identity is not itself a reason to disable the action that identifies it. The model may infer a native identity from the named application, and final concrete-target validation and readiness remain server-authoritative. Displayed readiness does not claim the model's interpretation is correct or that every same-named application has been ruled out. + +Quick Test consumes the shared prerequisite and command-readiness facts through the API adapter without rebuilding diagnosis or collapsing its metadata into generic Provider/Runtime badges. Environment refresh obtains current context-bound facts and retains existing loading, stale-response, and safe-error handling. + +Activating an enabled `Start Quick Test` immediately replaces the composer with the existing Explore activity-and-evidence workbench in the first UI update for the locally pending start request. Entry does not wait for the start response, a `requestId`, a `runId`, or successful preparation. Preparation and execution use this same workbench without another confirmation, route, preparation page, or intermediate single-column progress view. The submitted goal remains visible in the existing source summary, and duplicate submission and execution-context edits remain locked. + +The existing single task-state badge shows `Preparing` for the actual locally pending start before authoritative current server status is available, and for server status `preparing`. Authoritative current server status takes precedence over local pending state, including a direct error or terminal result without an intervening running state. The badge shows `Running` only for server status `running`; stream connectivity, a local task-attached/running flag, and the existence of a request or Run identity do not establish execution. Preparation adds no substeps, phase groups, percentages, synthetic timeline events, or completed checks; the timeline continues to show only actual server events. + +Quick Test does not display or announce internal transport-state labels during normal idle, initial connection, live updates, or normal terminal closure. Connection notices appear only for abnormal/degraded connectivity or unknown task state, using existing notice styling and accessible status/error semantics. Reconnection receives readable interruption/reconnecting feedback; polling fallback states that live updates are unavailable and task status is checked periodically, including when the browser lacks EventSource support, without claiming execution stopped. The connection notice disappears when normal operation resumes, without a recovery-success notification. Unknown start outcomes, task-identity mismatches, stream errors, and `run_ended` after server restart retain their safe messages and guidance even when the transport state is `ended`; hiding transport labels never suppresses these safeguards. + +This presentation leaves the `Preparing`, `Running`, and terminal task badges and authoritative results independent of connectivity; no connection state establishes success. Shared subscriptions, reconnection, polling, and execution remain unchanged, as does Case Manager presentation. It adds no layout, progress indicator, or retry action, and does not filter words from the Goal, timeline events, or Logs. + +The workbench reuses the existing timeline, Screen/UI Tree/Logs, selected-action evidence, replay, and outcome semantics. Before evidence exists, the shared evidence surfaces use their existing no-evidence presentation, not fabricated capture or readiness. Local pending presentation does not fabricate a request or Run identity, application, server snapshot, or evidence, and requests that require an identity wait until the server supplies that identity. Cancellation is unavailable before an accepted cancellable server task identity exists: no cancellation request, queued cancellation, or claim of cancellation is made. Once that identity is available, existing cancellation semantics apply. Earlier workbench entry does not change backend preparation, Workspace persistence, preflight, Agent execution, or evidence generation. + +Insufficient or ambiguous prompt, zero or multiple matches within the resolved target lookup, inference failure, discovery failure, and environment failure terminate preparation with distinct safe inline errors. An insufficient prompt explains that the prompt is too simple to infer test steps and asks the user to make the application and goal explicit. This is correction guidance, not a follow-up question: no candidate list, clarification dialog, alternative application, or automatic retry exists. The input and valid environment selections remain editable for a new explicit start. A failure after actual execution retains its evidence and cannot be labeled as a test that never started. + +Quick Test results preserve the backend's separate execution/verification/evidence outcomes. The terminal view offers `New test` and the existing `Open report` action when a persistent Run identity is available; it does not offer `Save yaml`, a Case editor, or a new history list. New test returns to the composer, retains the prompt and still-valid platform/browser choices plus the Android selected identity or explicit clearing intent, rechecks readiness, and never starts automatically. The dedicated Workspace remains available in Case Manager for history and Case operations. + +Navigation to Settings or another entry, refresh, and closing the tab do not cancel a server task. Returning to Quick Test restores its server-identified active preparation/run through bootstrap and snapshots without resubmitting. A task originating in Case Manager is shown only as shared busy state with navigation to that entry, not adopted as Quick Test even if it uses the same Workspace or Explore mode. Unknown start outcomes and disconnected streams require reconciliation; they do not enable a blind duplicate start. Service restart ends live recovery and does not imply success or automatic resumption. Idle prompt and selection state survive mounted navigation to/from Settings; reload recovery concerns active tasks, not persistent drafts. + +### Case Manager application shell and sidebar + +Case Manager renders the application-level `ControlPlaneShell` containing: - FSQ/Control Plane identity. - Centralized navigation metadata rendered in the exact top-to-bottom order Home, Test Runner, Runs, the Workspaces group, and bottom Settings. @@ -62,7 +106,7 @@ The entry renders one application-level `ControlPlaneShell` containing: `ControlPlaneSidebar` is independent of Devices. Feature pages provide active page identity, title-bar context/actions, and outlet content; they do not copy branding, navigation markup, workspace navigation, responsive collapse, or sidebar styles. Navigation metadata has typed ids, labels, icons, availability, and active state in one source. -Home, Workspace, Test Runner, Runs, and Settings are available. Without a Runs hash, initial load opens Home with no runnable Workspace selected. A valid Runs hash restores only historical scope. Settings retains single-Provider workflows and navigation guards; no placeholder destination exists. +Home, Workspace, Test Runner, Runs, and Settings are available in Case Manager. Entering Case Manager without a Runs hash opens Home with no runnable Workspace selected on a fresh mount. A valid Runs hash restores only historical scope. Settings retains single-Provider workflows and navigation guards; no placeholder destination exists. User-visible navigation/page names map to existing internal feature identifiers: Home uses overview, Test Runner uses devices, Workspaces uses workspace, and Settings uses config. Internal feature paths and backend Config transport names are unchanged. Elsewhere in this specification Overview, Devices, and Config refer to those existing implementation owners; their visible page titles and navigation labels use Home, Test Runner, and Settings. @@ -78,7 +122,7 @@ The sidebar is persistent on desktop. At narrow widths the shell owns one access ### Overview -Overview is the default available page. Its main content is centered within the page outlet with a maximum width of 1120px. Without a selection it shows the available/partial registered Workspace chooser and one visible Create workspace entry. With a selection it shows a compact identity/action bar and two side-by-side task regions for recording a Case and browsing/replaying existing Cases; the chooser opens on Change Workspace rather than remaining a second permanent list. Buttons keep content width and narrow layouts stack the task regions. Record new case opens Devices Explore with that Workspace and no platform selected; Browse Cases opens its file browser; Configure Workspace selects its configuration presentation. Selection can be cleared without changing persisted Workspace state. These are direct entry actions, not a persistent onboarding checklist. The main region has no unavailable third Run-inspection step and fabricates no Case/Run history. +Overview is the default available page within Case Manager. Its main content is centered within the page outlet with a maximum width of 1120px. Without a selection it shows the available/partial registered Workspace chooser and one visible Create workspace entry. With a selection it shows a compact identity/action bar and two side-by-side task regions for recording a Case and browsing/replaying existing Cases; the chooser opens on Change Workspace rather than remaining a second permanent list. Buttons keep content width and narrow layouts stack the task regions. Record new case opens Devices Explore with that Workspace and no platform selected; Browse Cases opens its file browser; Configure Workspace selects its configuration presentation. Selection can be cleared without changing persisted Workspace state. These are direct entry actions, not a persistent onboarding checklist. The main region has no unavailable third Run-inspection step and fabricates no Case/Run history. The independent compact Global AI configuration region shows Provider request/configuration state, safe Provider/model/authenticated summary and Manage Provider. Provider state is not Workspace progress. Overview does not duplicate configuration forms, execution inputs, preflight or evidence. @@ -206,6 +250,8 @@ Config API unavailable state explains that editable configuration requires a loo ### Devices context and discovery +The Devices setup, mode-selection, and Workspace-launch contracts in this section describe Case Manager. Quick Test reuses the execution/evidence presentation with the entry-specific setup, result actions, and recovery rules above rather than changing Case Manager's source requirements. + The Devices-owned page context region contains, in order: - Workspace selection sharing the application's in-memory selected workspace. @@ -221,7 +267,7 @@ During `preparing`, `running`, and `finalizing`, workspace, platform, and target ### Devices phase layout -The global title bar carries page/navigation context. Workspace, platform, target and refresh controls occupy one Devices-owned context region below it, preserving explicit selection and locking rules. Preparation uses a wide source/mode composer beside compact environment checks. Start and its first blocking explanation stay at the composer action edge. No large empty live-evidence panel is shown before a run is allocated. Unselected conditions show neutral setup guidance, discovery shows checking and only returned failed/unavailable diagnosis uses failure styling. +The global title bar carries page/navigation context. Workspace, platform, target and refresh controls occupy one Devices-owned context region below it, preserving explicit selection and locking rules. In Case Manager, preparation uses a wide source/mode composer beside compact environment checks. Start and its first blocking explanation stay at the composer action edge. Case Manager shows no large empty live-evidence panel before a run is allocated. Quick Test instead enters the shared activity-and-evidence workbench on the locally pending valid start as specified under Entry Choice And Quick Test; this does not change Case Manager's preparation layout. Unselected conditions show neutral setup guidance, discovery shows checking and only returned failed/unavailable diagnosis uses failure styling. Starting a run changes the page to an execution workbench with a narrower activity/Case-step column and larger evidence column. During execution and terminal review, the setup selectors collapse into a read-only context summary in one run bar that identifies backend status, Workspace, platform, target and source. It offers Cancel while cancellable and applicable terminal actions after completion. Source details remain expandable. Evidence tabs, Action selection and replay retain existing semantics. Desktop panes scroll independently below context/run bars. Narrow layouts keep setup controls in normal flow, stack preparation/execution panes and keep actions reachable without a tall fixed toolbar hiding content. @@ -337,13 +383,27 @@ Shared presentation ownership: Current application/shell ownership: -- `src/main.tsx`: React root. -- `src/app/ControlPlaneApp.tsx`: application composition and available-page selection. +- `src/main.tsx`: React root for the shared browser entry. +- `src/app/`: entry-choice and workflow composition, mounted navigation state, and cross-entry navigation guards. +- `src/app/EntryApp.tsx` and `src/app/entry.css`: responsive entry chooser, hash navigation, mounted workflow composition, and shared Settings navigation guards. +- `src/app/ControlPlaneApp.tsx`: complete Case Manager application composition and available-page selection. - `src/app/shell/ControlPlaneShell.tsx`: sidebar/title-bar/page-outlet geometry and narrow shell behavior. - `src/app/shell/ControlPlaneSidebar.tsx`: reusable navigation presentation. - `src/app/shell/navigation.ts`: typed centralized navigation metadata. - `src/app/shell/shell.css`: shared shell/sidebar layout, responsive, and focus styles. +Quick Test state ownership: + +- `src/features/quick-test/QuickTestPage.tsx` and `quick-test.css`: lightweight composer and composition of shared PreflightStatus, prerequisite, timeline and evidence views. +- `src/features/quick-test/useQuickTest.ts`: context binding, explicit start/reconciliation, server workflow identity and mounted prompt/selection state. +- `src/api/quickTest.test.ts`, `src/api/quickDiagnostics.test.ts`, `src/app/EntryApp.test.tsx`, `src/app/EntryApp.integration.test.tsx`, and `src/features/quick-test/*.test.ts*`: focused transport, entry, context, preparation and lifecycle regression tests, including mounted EntryApp/Case Manager browser-history and registry reconciliation. +- `src/test/quickTestFixtures.ts`: shared typed context-diagnostics fixtures for transport, presentation, and lifecycle tests. +- Application composition owns entry choice and preservation of each mounted entry's navigation context. +- Quick Test feature state owns the prompt, selected platform/device/browser, context-discovery generations, preparation request, and server-validated Quick Test task identity. It has no writable Workspace selector or Strict mode. +- Existing Devices run-stream and presentation components own the reused transport subscription, timeline, evidence, cancellation, and result presentation; Quick Test does not copy their execution state machine. +- The shared Config feature remains the only Provider editor. Quick Test sends no direct Provider, filesystem, discovery subprocess, or model requests. +- Backend identity, candidate matching, Workspace initialization, and final readiness remain authoritative; frontend state never synthesizes a runnable application or marks inference as test success. + Current Overview ownership: - `src/features/overview/OverviewPage.tsx`: global Provider summary, safe current-Workspace identity, Workspace chooser and direct Case actions, loading/empty/error presentation, derived Workspace/platform prerequisites, and navigation-command composition. @@ -403,11 +463,11 @@ The application owns the Config request used to derive Overview's read-only safe - Architecture level: Level 3 Layered Frontend Application. - Runtime boundary: React/TypeScript in the browser, consuming only `/api/control-plane/*`. -- State boundary: the application owns active page, Workspace registry request state, the single validated current Workspace, Overview's secret-free Provider request projection, and the consumed-once Workspace-to-Devices launch intent; Overview consumes those application-owned facts without requesting or persisting onboarding truth; Workspace and Devices own their feature server/request/interaction state, while Config owns editable Provider workflow state; dialogs and file/timeline views own transient disclosure state. +- State boundary: application composition owns entry choice and navigation; Case Manager owns its active page, Workspace registry request state, validated current Workspace, Overview's secret-free Provider projection, and consumed-once Workspace-to-Devices launch intent. Quick Test owns independent prompt/context/preparation state and attaches only to its server-identified tasks. Overview consumes application-owned facts without requesting or persisting onboarding truth; Workspace and Devices own feature server/request/interaction state, Config owns the shared editable Provider workflow, and dialogs/file/timeline views own transient disclosure state. - Integration boundary: `src/api/controlPlaneClient.ts` owns fetch/EventSource details and runtime response validation; `src/api/types.ts` owns transport types. - Dependency direction: feature components depend on shell props and API adapters; shell and API adapters do not import feature internals; frontend never imports Python implementation. - Current framework and language: React 19 with TypeScript/TSX and CSS in the existing Vite entry. -- Rationale: the entry separates an application shell, independent Workspace/Devices/Config workflows, explicit transport validation, and complex server/transient state; no router, global store, or additional data layer is required. +- Rationale: the entry separates a shared application shell, Quick Test and Case Manager workflows, explicit transport validation, and server/transient state while reusing execution presentation; no router, global store, second frontend application, or additional data layer is required. ## Build And Delivery @@ -449,6 +509,8 @@ Empty states direct the user to create/select a workspace, add a configured plat ## Verification Scope +Quick Test verification covers the dual-entry chooser, exactly two sidebar destinations, omitted Workspace/mode/application forms, all supported platform choices before platform initialization, target-independent checks, complete preparation/error/run/result states, no clarification or automatic retry, shared Settings, task-origin isolation, navigation/refresh recovery, and unchanged Case Manager report/Strict paths. Browser evidence at 1440x900, 1024x768, and 390x844 checks the entry cards and Quick Test setup, preparation error, running, and terminal surfaces with keyboard navigation, focus recovery, no horizontal overflow, reduced motion, and a clean console. Controlled API/model fixtures do not count as live platform-execution evidence. + Visual-consistency verification covers Home, Workspace create with all four platform field variants, Configuration summary, add/edit forms, expanded environment rows, Settings, Provider choice/auth/result dialogs, Save YAML dialogs, and Test Runner preparation/terminal surfaces. At 1440x900, 1024x768 and 390x844, long workspace names/paths/revisions and multiple status badges do not overlap or cause page-level horizontal overflow. Browser checks exercise initial focus, modal Tab containment, allowed Escape dismissal, focus restoration, loading/error/disabled states, and reduced motion without mutating real private configuration. Reviewed desktop/narrow screenshots include Workspace summary, create/edit and representative dialogs. The bundled logo loads from the local built application in source and installed-wheel smoke checks. @@ -457,7 +519,7 @@ Visual-consistency verification covers Home, Workspace create with all four plat - macOS Preflight tests cover missing prerequisites, optional/blocked checks, diagnostic request failure, copy success/failure, guide disclosure, refreshed readiness, stale-response rejection, explicit Start disabled reasons, duplicate-start locking, start-time environment failure, and repairable missing-application entry without ready Workspace promotion. Browser review covers the current macOS unavailable state plus representative ready/error/repair states at 1440×900 and 390×844, keyboard behavior, wrapped long commands, and a clean console. - A clean lock-file install, TypeScript check, focused frontend tests, and Vite build validate the entry. -- Shell/Home tests prove one centralized sidebar renders Home, Test Runner, available Runs, expandable Workspaces, and a single bottom Settings destination in the specified order without feature imports. They cover disclosure-only collapse/expand, page versus Workspace context, safe Provider projection, direct Case navigation, truthful registry/error states, keyboard order, `aria-current`, narrow drawer, and focus restoration. Reload without a Runs hash opens Home with no runnable Workspace selection; a valid Runs hash restores only historical scope. Runs navigation, including back/forward, retains draft-discard, pending-save, and unknown-save guards. Registry failure or selection loss revokes executable Workspace truth without fabricating history or execution readiness. +- Case Manager Shell/Home tests prove one centralized sidebar renders Home, Test Runner, available Runs, expandable Workspaces, and a single bottom Settings destination in the specified order without feature imports. They cover disclosure-only collapse/expand, page versus Workspace context, safe Provider projection, direct Case navigation, truthful registry/error states, keyboard order, `aria-current`, narrow drawer, and focus restoration. Fresh Case Manager entry without a Runs hash opens Home with no runnable Workspace selection; a valid Runs hash restores only historical scope and bypasses the entry chooser. Runs navigation, including back/forward, retains draft-discard, pending-save, and unknown-save guards. Registry failure or selection loss revokes executable Workspace truth without fabricating history or execution readiness. - Workspace tests cover malformed-response rejection; registry loading/empty/partial/unavailable/error/retry; safe summary/env omission; canonical platform ordering; all four target forms; multi-platform draft add/remove/clearing; selected/cancelled/malformed parent-folder responses; read-only selection, pending locking, replacement, retry, focus restoration, stale-response rejection, and long/narrow path layout; final-path preview; collapsed env rows; secret visibility; whole-create validation/submission locking; create conflict/error/focus; post-create selection; shared Workspace identity, complete path, platform labels, Files/Configuration navigation and visible Record action; Files/Configuration presentation and platform-tab behavior; unavailable platform guidance; additive platform save; no persisted delete affordance; edit-only private detail loading; clean/dirty/discard/pending/success/failure/independent revision-conflict update; private-value cleanup; selected-Workspace full-bleed Shell mode; accessible browser/File labels; record-new-case navigation; exact eligible FSQ path recognition and nested replay-path extraction; replay absence and metadata preservation for ineligible files; on-demand bounded tree state including platform directories; real workspace/file breadcrumb and metadata; Markdown Preview/Code safety; non-interactive Code presentation for other text; escaped text; missing/binary/invalid-UTF-8/oversized failures; `.fsq` absence; and stale-request cancellation. - Config tests cover malformed-response rejection, loading/empty/configured/unavailable states, one discriminated API-key/Azure draft, complete Azure save and key visibility, dirty-state discard behavior, provider replacement preservation, immediate GitHub device-flow start, authorization/model-discovery polling, no-default model selection, offered-model save eligibility, discovery error/empty retry, save success/failure, expiration/cancel cleanup, saved-only Test connection eligibility/results, dialog keyboard/focus behavior, and secret-safe presentation. - OpenAI, DeepSeek, Gemini, and Kimi verification covers response-contract validation, candidate-key model discovery, selection invalidation, safe expected errors, save-before-activation rejection, unknown-outcome reconciliation with delayed responses, busy navigation guards, and secret-free Home projection while preserving Azure/GitHub coverage. DeepSeek coverage includes backend discovery failure without partial-list selection, complete saved readback, Provider replacement, and Test Runner consumption of the shared readiness contract. Kimi coverage includes explicit empty region, region/key request identity, region/key-change invalidation, one-item no-default selection, exact API bodies, complete saved readback, Provider replacement, and Test Runner consumption of the shared readiness contract. Browser evidence at 1440x900, 1024x768, and 390x844 covers Settings forms and choice/result dialogs in the shared visual system, including async focus changes, return to a disabled-while-pending opener, reduced motion, recovery, and clean console output. Tests use isolated configuration and fake credentials rather than changing real private Provider state. @@ -468,12 +530,12 @@ Visual-consistency verification covers Home, Workspace create with all four plat ## Current Invariants - The shell/sidebar is application-level reusable code; Devices does not own or duplicate it. -- Home, Workspace, Test Runner, Runs, and Settings are available. Reload defaults to Home only without a Runs hash; valid Runs hashes restore historical scope independently of runnable Workspace selection. Settings remains the sole Provider workflow and Workspace the sole workspace-management workflow. Runs is backed by persisted history, not placeholder or fabricated content. +- The browser offers Quick Test and FSQ Case Manager unless an entry or report address selects a destination. Case Manager retains Home, Workspace, Test Runner, Runs, and Settings; its fresh non-report entry opens Home. Valid Runs hashes restore historical scope independently of runnable Workspace selection. Settings remains the sole Provider workflow and Workspace the sole manual workspace-management workflow. Quick Test's automatic dedicated Workspace preparation is server-owned. Runs is backed by persisted history, not placeholder or fabricated content. - Unimplemented navigation destinations are truthfully unavailable. - Sidebar workspace entries expose platform/status metadata but no parent or root paths; the selected Workspace title remains the path-bearing identity surface. - Backend responses are the source of truth for Provider configuration, Workspace registry/summary/platform detail/revision/files, and Devices readiness/targets/cases/timeline/task/evidence. Overview receives only an application-owned, secret-free Provider projection plus authoritative current Workspace identity and safe platform truth; it does not infer Case completion, latest Run, readiness, or evidence completion. - Registry error, selected-entry disappearance, or transition to unavailable revokes the application-wide current Workspace truth before Shell, Overview, Workspace, Devices, Record, or Replay can consume stale identity. -- Workspace selection is mounted-app memory shared by Workspace and Devices and resets on reload. Platform selection is explicit and separate: ordinary Devices entry never infers it, `Replay Case` explicitly offers the selected case path's platform, and any invalid offered or existing platform is cleared. +- Case Manager Workspace selection is mounted-app memory shared by Workspace and Devices and resets on reload; Quick Test's fixed Workspace never replaces it. Platform selection is explicit and separate: ordinary Devices entry never infers it, `Replay Case` explicitly offers the selected case path's platform, and any invalid offered or existing platform is cleared. - Workspace identity and persisted platform identity are read-only. Add persists only an absent platform; Edit submits one platform's complete target/env plus independent expected revision and never overwrites a conflict; no persisted platform can be deleted. - Workspace creation obtains its transient selected path only from the trusted-local parent-directory picker, keeps workspace name independent, and submits the `name`/`selectedPath`/`platforms` create transport. - Platform env values exist only in trusted-local create/add/edit state, load from the backend only after entering Edit for one available platform, are masked by default, cleared on confirmed navigation/unmount/success, and absent from frontend logs/errors. diff --git a/frontend/control-plane/src/api/controlPlaneClient.test.ts b/frontend/control-plane/src/api/controlPlaneClient.test.ts index 1d504f06..3db07f25 100644 --- a/frontend/control-plane/src/api/controlPlaneClient.test.ts +++ b/frontend/control-plane/src/api/controlPlaneClient.test.ts @@ -394,5 +394,5 @@ it('accepts unknown historical status and completed tool transport facts', async it('accepts backend recording warning with nullable live timestamp', () => { const value={requestId:'r',runId:'run',workspaceName:'w',platform:'web',targetId:'chrome',mode:'explore',status:'failed',source:{goal:'g'},startedAt:'2026-09-08T00:00:00Z',completedAt:null,cancelRequested:false,events:[{sequence:1,time:null,phase:'finalizing',label:'Dynamic recording',status:'failed',message:'Recording unavailable'}],activeStep:null,result:null,summary:'failed',screenshotRevision:0,uiSnapshotRevision:0,evidenceAvailable:false,reportAvailable:false,terminal:true}; - expect(validateRunSnapshot(value).events[0].time).toBeNull(); + expect(validateRunSnapshot({ ...value, workflow: 'case-manager', application: null, platformRevision: null }).events[0].time).toBeNull(); }); diff --git a/frontend/control-plane/src/api/controlPlaneClient.ts b/frontend/control-plane/src/api/controlPlaneClient.ts index a311f16d..c9ebde43 100644 --- a/frontend/control-plane/src/api/controlPlaneClient.ts +++ b/frontend/control-plane/src/api/controlPlaneClient.ts @@ -1,4 +1,5 @@ import type { + QuickTestContext, QuickTestPayload, QuickDiagnostics, HistoryResponse, RunReportResponse, ReportExportResponse, ReportFormat, ApiErrorBody, AzureConfigPayload, @@ -78,12 +79,19 @@ export class ControlPlaneApiError extends Error { } async function responseError(response: Response): Promise { - const candidate = await response.json().catch(() => null) as Partial | null; + const candidate: unknown = await response.json().catch(() => null); + if (!record(candidate) || !string(candidate.code) || !string(candidate.message) || !string(candidate.action)) { + return new ControlPlaneApiError(response.status, { + code: 'invalid_error_response', + message: 'The Control Plane server returned an invalid error response.', + action: 'Recheck the server task state before retrying.', + }); + } return new ControlPlaneApiError(response.status, { - code: candidate?.code ?? 'request_failed', - message: candidate?.message ?? `Request failed (${response.status}).`, - action: candidate?.action ?? 'Retry the request.', - details: candidate?.details, + code: candidate.code, + message: candidate.message, + action: candidate.action, + details: candidate.details, }); } @@ -116,8 +124,50 @@ function hasNoUnknownKeys(value: Record, keys: readonly string[ } function activeTask(value: unknown): boolean { - return record(value) && string(value.requestId) && nullableString(value.runId) && string(value.workspaceName) && platform(value.platform) - && string(value.targetId) && string(value.mode) && modes.has(value.mode) && string(value.status) && statuses.has(value.status); + if (!record(value) || !string(value.requestId) || !nullableString(value.runId) || !platform(value.platform) + || !string(value.mode) || !modes.has(value.mode) || !string(value.status) || !statuses.has(value.status)) return false; + if (value.workflow !== 'quick-test' && value.workflow !== 'case-manager') return false; + if (!nullableString(value.application) || !nullableString(value.platformRevision)) return false; + if (value.workflow === 'quick-test') { + return value.mode === 'explore' && nullableString(value.workspaceName) && nullableString(value.targetId) + && (value.runId === null || (string(value.workspaceName) && value.workspaceName.length > 0)) + && ((value.platform === 'windows' || value.platform === 'macos') || string(value.targetId)); + } + return string(value.workspaceName) && string(value.targetId); +} + +const diagnosticStatuses = new Set(['ready', 'unavailable', 'error', 'not_applicable', 'pending']); +function diagnosticDetail(value: unknown, nullableText = false): boolean { + return record(value) && string(value.status) && diagnosticStatuses.has(value.status) && nullableString(value.code) + && (nullableText ? nullableString(value.message) && nullableString(value.action) : string(value.message) && string(value.action)); +} +function quickDiagnostics(value: unknown): value is QuickDiagnostics { + if (!record(value) || value.workspaceName !== null || !platform(value.platformId) + || !['workspace', 'platform', 'provider', 'target', 'strict'].every(key => diagnosticDetail(value[key])) + || !arrayOf(value.prerequisites, item => record(item) && string(item.identifier) && nullableString(item.code) + && string(item.status) && diagnosticStatuses.has(item.status) && string(item.message) && nullableString(item.action) && arrayOf(item.commands, string)) + || !record(value.commands) || !hasOnlyKeys(value.commands, ['caseCreate', 'caseTest', 'caseTestSuggest', 'prepare']) + || !Object.values(value.commands).every(item => diagnosticDetail(item)) + || !record(value.checks) || !hasOnlyKeys(value.checks, ['configuration', 'runtime', 'target_configuration', 'target_availability', 'strict_core', 'provider', 'suggestion_analyzer', 'dynamic_agent']) + || !Object.values(value.checks).every(item => diagnosticDetail(item, true)) + || !string(value.status) || !['ready', 'partial', 'unavailable', 'pending'].includes(value.status) + || !string(value.checkedAt) || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value.checkedAt) + || !Number.isFinite(Date.parse(value.checkedAt)) + || ('targetId' in value && (value.platformId !== 'android' || !nullableString(value.targetId)))) return false; + return true; +} +function quickTestContext(value: unknown): value is QuickTestContext { + if (!record(value) || !platform(value.platform) || !nullableString(value.targetId) || !bool(value.canStart) + || !arrayOf(value.choices, item => record(item) && string(item.id) && string(item.label) && bool(item.selectable) && string(item.status) && readinessStatuses.has(item.status)) + || !arrayOf(value.checks, item => record(item) && string(item.id) && string(item.message) && string(item.action) && string(item.status) && diagnosticStatuses.has(item.status))) return false; + if (value.diagnostics === null) return true; + return quickDiagnostics(value.diagnostics) && value.diagnostics.platformId === value.platform + && (value.diagnostics.targetId === undefined || value.diagnostics.targetId === value.targetId) + && value.canStart === (value.diagnostics.commands.prepare.status === 'ready'); +} +function validateQuickTestContext(value: unknown): QuickTestContext { + if (!quickTestContext(value)) invalidResponse('quick-test context', 'Invalid choices, checks or context-bound diagnostics.'); + return value; } function readinessRecord(value: unknown): boolean { return record(value) && string(value.status) && readinessStatuses.has(value.status) && string(value.message) && string(value.action); @@ -148,6 +198,7 @@ export function validateRunSnapshot(value: unknown, path = 'run snapshot'): RunS || (value.recordingDraft !== undefined && value.recordingDraft !== null && !bool(value.recordingDraft))) { invalidResponse(path, 'Invalid run snapshot fields.'); } + if (value.error !== undefined && value.error !== null && (!record(value.error) || !string(value.error.code) || !string(value.error.message) || !string(value.error.action))) invalidResponse(path, 'Invalid preparation error.'); return value as unknown as RunSnapshot; } @@ -496,6 +547,12 @@ export const controlPlaneClient = { return response.text(); }, bootstrap: (signal?: AbortSignal) => jsonRequest('/bootstrap', validateBootstrap, { signal }), + quickTestContext: (platform: PlatformId, targetId?: string, signal?: AbortSignal) => { + const params = new URLSearchParams({ platform }); + if ((platform === 'android' || platform === 'web') && targetId) params.set('targetId', targetId); + return jsonRequest(`/quick-test/context?${params}`, validateQuickTestContext, { signal }); + }, + startQuickTest: (payload: QuickTestPayload) => jsonRequest('/quick-test/runs', validateStart, { method: 'POST', body: JSON.stringify(payload) }), readiness: (workspaceName: string, platform: PlatformId, signal?: AbortSignal, targetId?: string | null) => platform === 'android' ? jsonRequest('/readiness', validateReadiness, { method: 'POST', body: JSON.stringify({workspaceName, platform, targetId: targetId || null}), signal }) : jsonRequest(`/readiness?workspace=${encodeURIComponent(workspaceName)}&platform=${encodeURIComponent(platform)}`, validateReadiness, { signal }), diff --git a/frontend/control-plane/src/api/quickDiagnostics.test.ts b/frontend/control-plane/src/api/quickDiagnostics.test.ts new file mode 100644 index 00000000..a0fc507e --- /dev/null +++ b/frontend/control-plane/src/api/quickDiagnostics.test.ts @@ -0,0 +1,61 @@ +import { controlPlaneClient } from './controlPlaneClient'; +import { quickDiagnostics } from '../test/quickTestFixtures'; +import androidConfigError from '../test/quick-test-android-config-error.json'; + +afterEach(() => vi.restoreAllMocks()); +const diagnostics = quickDiagnostics(); +const context = { platform: 'macos', targetId: null, canStart: true, choices: [], checks: [], diagnostics }; +it('decodes the real HTTP configuration failure with matching selected-device identity', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(androidConfigError))); + const decoded = await controlPlaneClient.quickTestContext('android', 'selected-device'); + expect(decoded).toEqual(androidConfigError); + expect(decoded.targetId).toBe('selected-device'); + expect(decoded.diagnostics?.targetId).toBe('selected-device'); + expect(decoded.diagnostics?.checks.configuration).toMatchObject({ code: 'doctor.configuration_invalid', status: 'error' }); + expect(decoded.diagnostics?.commands.prepare.status).toBe('error'); + expect(decoded.canStart).toBe(false); +}); +it('still rejects the old null nested identity in the actual captured configuration failure', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + ...androidConfigError, diagnostics: { ...androidConfigError.diagnostics, targetId: null }, + }))); + await expect(controlPlaneClient.quickTestContext('android', 'selected-device')).rejects.toMatchObject({ + body: { code: 'invalid_response' }, + }); +}); +it('accepts full pending diagnostics, nullable raw facts and not-applicable compatibility checks', async () => { + const body = { ...context, checks: [{ id: 'suggestion_analyzer', status: 'not_applicable', message: 'Not needed', action: '' }], diagnostics: { ...diagnostics, checks: { ...diagnostics.checks, suggestion_analyzer: { status: 'not_applicable', code: null, message: null, action: null } } } }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(body))); + expect(await controlPlaneClient.quickTestContext('macos')).toEqual(body); +}); +it.each([ + ['missing diagnostics', { ...context, diagnostics: undefined }], + ['missing command', { ...context, diagnostics: { ...diagnostics, commands: { ...diagnostics.commands, prepare: undefined } } }], + ['bad command status', { ...context, diagnostics: { ...diagnostics, commands: { ...diagnostics.commands, prepare: { ...diagnostics.commands.prepare, status: 'unknown' } } } }], + ['bad prerequisite commands', { ...context, diagnostics: { ...diagnostics, prerequisites: [{ ...diagnostics.prerequisites[0], commands: [42] }] } }], + ['bad raw detail', { ...context, diagnostics: { ...diagnostics, checks: { ...diagnostics.checks, runtime: { ...diagnostics.checks.runtime, message: {} } } } }], + ['missing raw check', { ...context, diagnostics: { ...diagnostics, checks: { ...diagnostics.checks, dynamic_agent: undefined } } }], + ['missing code', { ...context, diagnostics: { ...diagnostics, target: { ...diagnostics.target, code: undefined } } }], + ['invalid checked time', { ...context, diagnostics: { ...diagnostics, checkedAt: 'not-a-date' } }], + ['invented workspace', { ...context, diagnostics: { ...diagnostics, workspaceName: 'quick-test' } }], + ['different platform', { ...context, diagnostics: { ...diagnostics, platformId: 'web' } }], + ['inconsistent prepare verdict', { ...context, canStart: false }], + ['different Android target', { ...context, platform: 'android', targetId: 'serial', diagnostics: quickDiagnostics('android', 'other') }], + ['lost Android target in configuration failure', { + ...context, platform: 'android', targetId: 'serial', canStart: false, + diagnostics: { + ...quickDiagnostics('android', null), + commands: { + ...quickDiagnostics('android', null).commands, + prepare: { status: 'error', code: 'configuration_error', message: 'Unable to load configuration.', action: 'Repair configuration.' }, + }, + }, + }], +])('rejects %s without flattening or fallback', async (_name, body) => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(body))); + await expect(controlPlaneClient.quickTestContext('macos')).rejects.toMatchObject({ body: { code: 'invalid_response' } }); +}); +it('accepts explicit legacy null without manufacturing diagnostics', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ ...context, diagnostics: null }))); + expect((await controlPlaneClient.quickTestContext('macos')).diagnostics).toBeNull(); +}); diff --git a/frontend/control-plane/src/api/quickTest.test.ts b/frontend/control-plane/src/api/quickTest.test.ts new file mode 100644 index 00000000..42b04920 --- /dev/null +++ b/frontend/control-plane/src/api/quickTest.test.ts @@ -0,0 +1,69 @@ +import { controlPlaneClient, validateRunSnapshot } from './controlPlaneClient'; +import { quickDiagnostics } from '../test/quickTestFixtures'; + +afterEach(() => vi.restoreAllMocks()); +it('F007: malformed JSON has a fixed safe error, while valid server errors retain their facts', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('bad gateway', { status: 502 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ code: 'busy', message: 'Another task is active.', action: 'Wait for completion.', details: { requestId: 'other' } }), { status: 409 })); + await expect(controlPlaneClient.quickTestContext('macos')).rejects.toMatchObject({ status: 502, body: { code: 'invalid_error_response' } }); + await expect(controlPlaneClient.startQuickTest({ platform: 'macos', goal: 'Check Notes' })).rejects.toMatchObject({ status: 409, body: { code: 'busy', message: 'Another task is active.', action: 'Wait for completion.', details: { requestId: 'other' } } }); + expect(fetch).toHaveBeenCalledTimes(2); +}); +it.each([null, [], { code: 'failure', message: { unexpected: 'shape' }, action: 'Recheck' }, { code: 'failure', message: 'Failed', action: {} }, { code: 42, message: 'Failed', action: 'Recheck' }])('F007: safely rejects malformed error envelopes while retaining HTTP status', async body => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(body), { status: 503 })); + await expect(controlPlaneClient.startQuickTest({ platform: 'macos', goal: 'Check Notes' })).rejects.toMatchObject({ + status: 503, body: { code: 'invalid_error_response', message: 'The Control Plane server returned an invalid error response.', action: 'Recheck the server task state before retrying.' }, + }); +}); +const context = { platform: 'macos', targetId: null, canStart: true, choices: [], checks: [{ id: 'application', status: 'pending', message: 'Awaiting identification', action: '' }], diagnostics: quickDiagnostics() }; +const preparation = { + requestId: 'q1', workflow: 'quick-test', application: null, platformRevision: null, + workspaceName: null, targetId: null, runId: null, platform: 'macos', mode: 'explore', status: 'preparing', + source: { goal: 'Open Notes and verify a new note' }, startedAt: '', completedAt: null, cancelRequested: false, + events: [], activeStep: null, result: null, summary: 'Identifying application', screenshotRevision: 0, + uiSnapshotRevision: 0, evidenceAvailable: false, reportAvailable: false, terminal: false, error: null, +}; + +it('accepts unresolved Quick preparation without invented identities', () => { + expect(validateRunSnapshot(preparation)).toEqual(preparation); + expect(() => validateRunSnapshot({ ...preparation, workflow: 'other' })).toThrow(); + expect(() => validateRunSnapshot({ ...preparation, workflow: undefined })).toThrow(); + expect(() => validateRunSnapshot({ ...preparation, workflow: undefined, workspaceName: 'quick-test', targetId: 'app' })).toThrow(); + expect(() => validateRunSnapshot({ ...preparation, error: { code: 3 } })).toThrow(); +}); + +it('accepts pending application checks and omits desktop targetId from context and start', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify(context))) + .mockResolvedValueOnce(new Response(JSON.stringify({ requestId: 'q1' }), { status: 202 })); + expect(await controlPlaneClient.quickTestContext('macos')).toEqual(context); + expect(String(fetch.mock.calls[0][0])).toBe('/api/control-plane/quick-test/context?platform=macos'); + await controlPlaneClient.startQuickTest({ platform: 'macos', goal: 'Verify Notes' }); + expect(JSON.parse(String(fetch.mock.calls[1][1]?.body))).toEqual({ platform: 'macos', goal: 'Verify Notes' }); +}); + +it.each([ + { ...context, checks: [{ id: 'application', status: 'unknown', message: '', action: '' }] }, + { ...context, choices: [{ id: 'x', label: 'x', selectable: true, status: 'unknown' }] }, + { ...context, canStart: 'true' }, +])('rejects malformed context without fallback', async (body) => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(body))); + await expect(controlPlaneClient.quickTestContext('macos')).rejects.toMatchObject({ body: { code: 'invalid_response' } }); +}); + +it.each([['android', 'serial:5555'], ['web', 'chrome-beta']] as const)('binds %s without workspace, mode, Case or Provider fields', async (platform, targetId) => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ ...context, platform, targetId, diagnostics: quickDiagnostics(platform, targetId) }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ requestId: 'q1' }), { status: 202 })); + await controlPlaneClient.quickTestContext(platform, targetId); + const url = new URL(String(fetch.mock.calls[0][0]), 'http://localhost'); + expect([...url.searchParams]).toEqual([['platform', platform], ['targetId', targetId]]); + await controlPlaneClient.startQuickTest({ platform, targetId, goal: 'Check the application' }); + expect(JSON.parse(String(fetch.mock.calls[1][1]?.body))).toEqual({ platform, targetId, goal: 'Check the application' }); +}); + +it('accepts Quick bootstrap provenance but never treats unknown workflow as Manager', async () => { + const body = { apiVersion: '1', platforms: [{ id: 'macos', label: 'macOS' }], busy: true, activeTask: preparation }; + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(body))); + expect((await controlPlaneClient.bootstrap()).activeTask?.workflow).toBe('quick-test'); + fetch.mockResolvedValue(new Response(JSON.stringify({ ...body, activeTask: { ...preparation, workflow: 'unknown' } }))); + await expect(controlPlaneClient.bootstrap()).rejects.toMatchObject({ body: { code: 'invalid_response' } }); +}); diff --git a/frontend/control-plane/src/api/types.ts b/frontend/control-plane/src/api/types.ts index 9c9cbd80..da910c9a 100644 --- a/frontend/control-plane/src/api/types.ts +++ b/frontend/control-plane/src/api/types.ts @@ -23,9 +23,13 @@ export interface PlatformOption { id: PlatformId; label: string } export interface ActiveTaskSummary { requestId: string; runId: string | null; - workspaceName: string; + workspaceName: string | null; platform: PlatformId; - targetId: string; + targetId: string | null; + /** Transport requires explicit provenance; absent local state never implies Quick Test. */ + workflow?: 'quick-test' | 'case-manager'; + application?: string | null; + platformRevision?: string | null; mode: RunMode; status: TaskStatus; } @@ -37,8 +41,26 @@ export interface BootstrapResponse { } export type ReadinessStatus = 'ready' | 'unavailable' | 'error'; +export type DiagnosticStatus = ReadinessStatus | 'not_applicable' | 'pending'; export interface ReadinessRecord { status: ReadinessStatus; message: string; action: string } -export interface PrerequisiteRecord { identifier: string; code?: string | null; status: ReadinessStatus | 'not_applicable'; message: string; action?: string | null; commands: string[] } +export interface PrerequisiteRecord { identifier: string; code?: string | null; status: DiagnosticStatus; message: string; action?: string | null; commands: string[] } +export interface DiagnosticDetail { status: DiagnosticStatus; code: string | null; message: string; action: string } +export interface RawDiagnosticDetail { status: DiagnosticStatus; code: string | null; message: string | null; action: string | null } +export interface QuickDiagnostics { + workspaceName: null; + platformId: PlatformId; + workspace: DiagnosticDetail; + platform: DiagnosticDetail; + provider: DiagnosticDetail; + target: DiagnosticDetail; + strict: DiagnosticDetail; + prerequisites: (PrerequisiteRecord & { code: string | null; action: string | null })[]; + commands: Record<'caseCreate' | 'caseTest' | 'caseTestSuggest' | 'prepare', DiagnosticDetail>; + checks: Record<'configuration' | 'runtime' | 'target_configuration' | 'target_availability' | 'strict_core' | 'provider' | 'suggestion_analyzer' | 'dynamic_agent', RawDiagnosticDetail>; + status: 'ready' | 'partial' | 'unavailable' | 'pending'; + checkedAt: string; + targetId?: string | null; +} export interface ReadinessResponse { workspaceName: string; platformId: PlatformId; @@ -115,6 +137,7 @@ export interface StrictCaseStep { message?: string | null; } export interface RunSnapshot extends ActiveTaskSummary { + error?: ApiErrorBody | null; suggestedCaseName?: string | null; recordingDraft?: boolean | null; source: { goal?: string; casePath?: string; caseContent?: string; caseSteps?: StrictCaseStep[] }; @@ -135,6 +158,17 @@ export type StartRunPayload = | { mode: 'explore'; workspaceName: string; platform: PlatformId; targetId: string; goal: string } | { mode: 'strict'; workspaceName: string; platform: PlatformId; targetId: string; casePath: string }; export interface StartRunResponse { requestId: string } +export type QuickTestPayload = + | { platform: 'android' | 'web'; targetId: string; goal: string } + | { platform: 'windows' | 'macos'; goal: string }; +export interface QuickTestContext { + platform: PlatformId; + targetId: string | null; + canStart: boolean; + choices: { id: string; label: string; selectable: boolean; status: ReadinessStatus }[]; + checks: { id: string; status: DiagnosticStatus; message: string; action: string }[]; + diagnostics: QuickDiagnostics | null; +} export interface UiSnapshotResponse { revision: number; timestamp: string | null; diff --git a/frontend/control-plane/src/app/ControlPlaneApp.test.tsx b/frontend/control-plane/src/app/ControlPlaneApp.test.tsx index 058cecda..0ddfe25c 100644 --- a/frontend/control-plane/src/app/ControlPlaneApp.test.tsx +++ b/frontend/control-plane/src/app/ControlPlaneApp.test.tsx @@ -4,6 +4,16 @@ import userEvent from '@testing-library/user-event'; import { controlPlaneClient } from '../api/controlPlaneClient'; import { ControlPlaneApp } from './ControlPlaneApp'; +it('unmounts inactive Manager Settings so the other entry owns the single visible Provider form', async () => { + const { rerender } = render(); + await userEvent.click(screen.getByRole('button', { name: 'Settings' })); + expect(screen.getByText('Config content')).toBeVisible(); + rerender(); + expect(screen.queryByText('Config content')).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText('Config content')).toBeVisible(); +}); + vi.mock('../api/controlPlaneClient', () => ({ controlPlaneClient: { workspaces: vi.fn().mockResolvedValue({ workspaces: [] }), config: vi.fn().mockResolvedValue({ configured: false, provider: null }), configStatus: vi.fn().mockResolvedValue({ configured: false, provider: null, modelName: null, sessionStatus: 'not_configured', authenticated: false, message: 'Not configured.' }) }, toApiError: vi.fn((error) => error), diff --git a/frontend/control-plane/src/app/ControlPlaneApp.tsx b/frontend/control-plane/src/app/ControlPlaneApp.tsx index ab7360c8..3ef525e2 100644 --- a/frontend/control-plane/src/app/ControlPlaneApp.tsx +++ b/frontend/control-plane/src/app/ControlPlaneApp.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'; import { ControlPlaneShell } from './shell/ControlPlaneShell'; import type { ControlPlanePageId, WorkspaceNavigationItem } from './shell/navigation'; import { controlPlaneClient, toApiError } from '../api/controlPlaneClient'; @@ -14,7 +14,11 @@ type DevicesLaunchRequest = | Omit, 'id'> | Omit, 'id'>; -export function ControlPlaneApp() { +export function ControlPlaneApp({ active = true, entryGuard, onBackToStart }: { active?: boolean; entryGuard?: MutableRefObject<() => boolean>; onBackToStart?: () => void } = {}) { + const activeRef = useRef(active); + const wasActive = useRef(active); + activeRef.current = active; + const [discardVersion, setDiscardVersion] = useState(0); const [activePage, setActivePageState] = useState<'overview' | 'workspace' | 'devices' | 'runs' | 'config'>(() => parseRunRoute(window.location.hash) ? 'runs' : 'overview'); const [runRoute, setRunRoute] = useState(() => parseRunRoute(window.location.hash) ?? {}); const [configDirty, setConfigDirty] = useState(false); @@ -112,11 +116,12 @@ export function ControlPlaneApp() { }; useEffect(() => { + if (!active) return; const controller = new AbortController(); void refreshWorkspaces(controller.signal); void refreshOverviewProvider(controller.signal); return () => controller.abort(); - }, []); + }, [active]); useEffect(() => { if (!focusCreatedWorkspace.current || !selectedWorkspaceName || !selectedWorkspace) return; @@ -135,23 +140,43 @@ export function ControlPlaneApp() { if (current.activePage === 'workspace' && current.workspaceDirty && !window.confirm('Discard unsaved workspace changes?')) return false; return true; }; + if (entryGuard) entryGuard.current = () => { + if (!canDiscardDraft('quick-test')) return false; + if (navigationGuard.current.configDirty || navigationGuard.current.workspaceDirty || navigationGuard.current.configUncertain) setDiscardVersion(value => value + 1); + setConfigDirty(false); setConfigUncertain(false); setWorkspaceDirty(false); + return true; + }; + useEffect(() => { + const reactivating = active && !wasActive.current; + wasActive.current = active; + if (!active) return; + const route = parseRunRoute(window.location.hash); + const savedPage: unknown = reactivating ? window.history.state?.cpPage : undefined; + const page = route ? 'runs' : savedPage === 'overview' || savedPage === 'workspace' || savedPage === 'devices' || savedPage === 'config' + ? savedPage : acceptedNavigation.current.page; + if (route) setRunRoute(route); + setActivePageState(page); + acceptedNavigation.current = { href: window.location.href, index: Number(window.history.state?.cpIndex ?? acceptedNavigation.current.index), page }; + }, [active]); const setActivePage = (page: typeof activePage, route?: RunRoute) => { const url = page === 'runs' ? runHash(route ?? runRoute) : window.location.pathname + window.location.search; const index = acceptedNavigation.current.index + 1; - window.history.pushState({ cpIndex: index, cpPage: page }, '', url); + window.history.pushState({ cpEntry: 'case-manager', cpIndex: index, cpPage: page }, '', url); acceptedNavigation.current = { href: window.location.href, index, page }; setActivePageState(page); }; useEffect(() => { - window.history.replaceState({ ...window.history.state, cpIndex: acceptedNavigation.current.index, cpPage: acceptedNavigation.current.page }, '', window.location.href); + window.history.replaceState({ ...window.history.state, cpEntry: 'case-manager', cpIndex: acceptedNavigation.current.index, cpPage: acceptedNavigation.current.page }, '', window.location.href); const onLocationChange = () => { + if (!activeRef.current || ['#start', '#quick-test', '#quick-test/settings'].includes(window.location.hash)) return; + if (entryGuard && !window.location.hash && window.history.state?.cpEntry !== 'case-manager') return; if (window.location.href === acceptedNavigation.current.href && window.history.state?.cpPage === acceptedNavigation.current.page) return; const route = parseRunRoute(window.location.hash); const savedPage = window.history.state?.cpPage; const destination = route ? 'runs' : ['overview', 'workspace', 'devices', 'config'].includes(savedPage) ? savedPage : 'overview'; if (!canDiscardDraft(destination)) { const old = acceptedNavigation.current; - window.history.replaceState({ cpIndex: old.index, cpPage: old.page }, '', old.href); + window.history.replaceState({ cpEntry: 'case-manager', cpIndex: old.index, cpPage: old.page }, '', old.href); return; } setConfigDirty(false); setWorkspaceDirty(false); @@ -268,6 +293,7 @@ export function ControlPlaneApp() { message: workspace.status === 'unavailable' ? `${workspace.message} ${workspace.action}` : undefined, })); const shellWorkspaceProps = { + onBackToStart, interactionLocked: startPending || workspacePending || configPending, workspaces: workspaceNavigation, selectedWorkspaceId: selectedWorkspace?.name ?? null, @@ -308,7 +334,7 @@ export function ControlPlaneApp() { outletPresentation={workspaceOutletPresentation} titleContent={selectedWorkspace && !createRequested ? launchDevices({mode:'explore',workspaceName:selectedWorkspace.name})} recordDisabled={workspacePending || !selectedWorkspace.platforms.some(p=>p.status==='available')} recordDisabledReason={workspacePending ? 'Wait for the Workspace configuration to finish saving.' : undefined} /> : undefined} onNavigate={navigate} {...shellWorkspaceProps} - >; + >{active && }; if (activePage === 'runs') return { void refreshWorkspaces(); }} onNavigate={navigateRuns} />; - return navigateRuns({ workspace, platform, run })} onStartPendingChange={handleStartPendingChange} onRepairTarget={(name,platform)=>{if(startPendingRef.current)return;if(platform==='android'){setDiagnosticWorkspaceName(null);setSelectedWorkspaceName(name);}else{setDiagnosticWorkspaceName(name);setSelectedWorkspaceName(null);}setWorkspaceConfigurationOpen(true);setCreateRequested(false);setWorkspaceOutletPresentation('default');setActivePage('workspace');}} diff --git a/frontend/control-plane/src/app/EntryApp.integration.test.tsx b/frontend/control-plane/src/app/EntryApp.integration.test.tsx new file mode 100644 index 00000000..d1607880 --- /dev/null +++ b/frontend/control-plane/src/app/EntryApp.integration.test.tsx @@ -0,0 +1,176 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { EntryApp } from './EntryApp'; +import { controlPlaneClient } from '../api/controlPlaneClient'; +import { quickDiagnostics } from '../test/quickTestFixtures'; +import type { BootstrapResponse, RunSnapshot, WorkspaceRegistryEntry } from '../api/types'; + +// Real EntryApp + Manager + Overview/navigation; only the Provider editor is a guard fixture. +vi.mock('../features/config/ConfigPage', () => ({ + ConfigPage: ({ onDirtyChange, onSavePendingChange, onSaveUncertainChange }: { + onDirtyChange: (value: boolean) => void; + onSavePendingChange: (value: boolean) => void; + onSaveUncertainChange: (value: boolean) => void; + }) =>
+ + + +
, +})); +const workspace: WorkspaceRegistryEntry = { name: 'existing', rootPath: '/isolated/existing', status: 'available', message: 'Available', platforms: [{ platform: 'web', status: 'available', message: 'Available', configPath: '/isolated/existing/config.web.yaml' }] }; +beforeEach(() => { + window.history.replaceState(null, '', '/'); + vi.spyOn(controlPlaneClient, 'workspaces').mockResolvedValue({ workspaces: [workspace] }); + vi.spyOn(controlPlaneClient, 'config').mockResolvedValue({ configured: false, provider: null }); + vi.spyOn(controlPlaneClient, 'configStatus').mockResolvedValue({ configured: false, provider: null, modelName: null, sessionStatus: 'not_configured', authenticated: false, message: 'Not configured' }); + vi.spyOn(controlPlaneClient, 'bootstrap').mockResolvedValue({ apiVersion: '1', platforms: [], busy: false, activeTask: null }); +}); +afterEach(() => vi.restoreAllMocks()); +async function travel(direction: 'back' | 'forward' | number, hash: string) { + await act(async () => { + const event = new Promise(resolve => window.addEventListener('popstate', () => resolve(), { once: true })); + if (typeof direction === 'number') window.history.go(direction); + else window.history[direction](); + await event; + }); + await waitFor(() => expect(window.location.hash).toBe(hash)); +} +async function enterManager() { + fireEvent.click(screen.getByRole('button', { name: 'FSQ Case Manager' })); + await screen.findByRole('heading', { name: 'Choose where to test' }); + await waitFor(() => expect(controlPlaneClient.workspaces).toHaveBeenCalled()); +} +it('F002: Back/Forward distinguishes original chooser from Manager internal hashless pages', async () => { + render(); + await enterManager(); + await travel('back', ''); + expect(screen.getByRole('button', { name: 'FSQ Case Manager' })).toBeVisible(); + await travel('forward', '#case-manager'); + expect(screen.getByRole('heading', { name: 'Choose where to test' })).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + expect(window.location.hash).toBe(''); + expect(screen.getByRole('region', { name: 'Provider editor' })).toBeVisible(); + await travel('back', '#case-manager'); + expect(screen.getByRole('heading', { name: 'Choose where to test' })).toBeVisible(); + await travel('forward', ''); + expect(screen.getByRole('region', { name: 'Provider editor' })).toBeVisible(); +}); +it('F002: multi-entry Back followed by Forward restores the recorded Manager page, not its last hidden page', async () => { + render(); + await enterManager(); + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + await travel(-2, ''); + expect(screen.getByRole('button', { name: 'FSQ Case Manager' })).toBeVisible(); + await travel('forward', '#case-manager'); + expect(screen.getByRole('heading', { name: 'Choose where to test' })).toBeVisible(); + await travel('forward', ''); + expect(screen.getByRole('region', { name: 'Provider editor' })).toBeVisible(); +}); +it.each(['Dirty', 'Pending', 'Unknown'])('F002: real Manager %s guard survives browser navigation', async state => { + render(); + await enterManager(); + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + fireEvent.click(screen.getByRole('button', { name: `${state} Provider` })); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + await travel('back', ''); + expect(screen.getByRole('region', { name: 'Provider editor' })).toBeVisible(); + if (state === 'Pending') expect(confirm).not.toHaveBeenCalled(); + else expect(confirm).toHaveBeenCalled(); + // A second Back attempts the original chooser rather than another Manager page. + await travel('back', ''); + expect(screen.getByRole('region', { name: 'Provider editor' })).toBeVisible(); + expect(window.history.state?.cpEntry).toBe('case-manager'); +}); +it.each(['create', 'add-platform'])('F005: returning refreshes %s inventory without replacing explicit selection', async change => { + const quick: WorkspaceRegistryEntry = { ...workspace, name: 'quick-test' }; + if (change === 'add-platform') vi.mocked(controlPlaneClient.workspaces).mockResolvedValue({ workspaces: [workspace, quick] }); + render(); + await enterManager(); + const chooser = screen.getByRole('region', { name: 'Choose a Workspace' }); + fireEvent.click(within(chooser).getByRole('button', { name: /existing/ })); + await screen.findByRole('heading', { name: 'Start testing in existing' }); + fireEvent.click(screen.getByRole('link', { name: 'Back to start' })); + fireEvent.click(screen.getByRole('button', { name: 'Quick Test' })); + const updated = { ...quick, platforms: [...quick.platforms, { platform: 'android' as const, status: 'available' as const, message: 'Available', configPath: '/isolated/quick/config.android.yaml' }] }; + let complete!: (value: { workspaces: WorkspaceRegistryEntry[] }) => void; + vi.mocked(controlPlaneClient.workspaces).mockImplementationOnce(() => new Promise(resolve => { complete = resolve; })); + fireEvent.click(screen.getByRole('link', { name: 'Back to start' })); + fireEvent.click(screen.getByRole('button', { name: 'FSQ Case Manager' })); + expect(screen.getByText('Loading registered Workspaces…')).toBeVisible(); + expect(screen.queryByRole('heading', { name: 'Start testing in existing' })).not.toBeInTheDocument(); + await act(async () => complete({ workspaces: [workspace, updated] })); + await screen.findByRole('button', { name: /quick-test.*web, android/ }); + expect(screen.getByRole('heading', { name: 'Start testing in existing' })).toBeVisible(); + expect(controlPlaneClient.workspaces).toHaveBeenCalledTimes(2); +}); + +it.each(['valid', 'removed', 'unavailable'])('F004/F005 residual: real Runner preserves only %s platform/target through activation registry revalidation', async validity => { + const ready = { status: 'ready' as const, message: 'Ready', action: '' }; + let busy = false; + const task: RunSnapshot = { + requestId: 'quick-request', workflow: 'quick-test', platform: 'web', targetId: 'chrome', mode: 'explore', + workspaceName: null, application: null, platformRevision: null, runId: null, status: 'preparing', + source: { goal: 'Check https://example.test title' }, startedAt: '', completedAt: null, cancelRequested: false, + events: [], activeStep: null, result: null, summary: 'Preparing', screenshotRevision: 0, uiSnapshotRevision: 0, + evidenceAvailable: false, reportAvailable: false, terminal: false, error: null, + }; + const idle: BootstrapResponse = { apiVersion: '1', platforms: [{ id: 'web', label: 'Web' }], busy: false, activeTask: null }; + vi.mocked(controlPlaneClient.bootstrap).mockImplementation(async () => busy ? { ...idle, busy: true, activeTask: task } : idle); + vi.spyOn(controlPlaneClient, 'readiness').mockResolvedValue({ workspaceName: workspace.name, platformId: 'web', workspace: ready, platform: ready, provider: ready, target: ready, strict: ready }); + vi.spyOn(controlPlaneClient, 'targets').mockResolvedValue({ platform: 'web', targetLabel: 'Browser', targets: ['chrome', 'chrome-beta'].map((id, index) => ({ id, label: id, description: '', status: 'ready', selectable: true, isDefault: index === 0, metadata: {} })) }); + vi.spyOn(controlPlaneClient, 'cases').mockResolvedValue({ platform: 'web', cases: [], truncated: false }); + vi.spyOn(controlPlaneClient, 'startRun').mockResolvedValue({ requestId: 'must-not-start' }); + vi.spyOn(controlPlaneClient, 'quickTestContext').mockImplementation(async (platform, targetId) => ({ platform, targetId: targetId ?? null, canStart: true, checks: [], choices: [{ id: 'chrome', label: 'Chrome', selectable: true, status: 'ready' }], diagnostics: quickDiagnostics(platform, targetId ?? null) })); + vi.spyOn(controlPlaneClient, 'startQuickTest').mockImplementation(async () => { busy = true; return { requestId: task.requestId }; }); + vi.spyOn(controlPlaneClient, 'runSnapshot').mockResolvedValue(task); + vi.spyOn(controlPlaneClient, 'cancelRun').mockResolvedValue(task); + render(); + await enterManager(); + fireEvent.click(screen.getByRole('button', { name: 'Test Runner' })); + fireEvent.change(screen.getByLabelText('Workspace'), { target: { value: workspace.name } }); + fireEvent.change(screen.getByRole('combobox', { name: 'Platform' }), { target: { value: 'web' } }); + await screen.findByRole('option', { name: 'chrome-beta' }); + fireEvent.change(screen.getByRole('combobox', { name: 'Browser' }), { target: { value: 'chrome-beta' } }); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Keep Manager goal' } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Start exploration' })).toBeEnabled()); + fireEvent.click(screen.getByRole('link', { name: 'Back to start' })); + fireEvent.click(screen.getByRole('button', { name: 'Quick Test' })); + await screen.findByRole('option', { name: 'Web' }); + fireEvent.change(screen.getByRole('combobox', { name: 'Platform' }), { target: { value: 'web' } }); + await screen.findByRole('option', { name: 'Chrome' }); + fireEvent.change(screen.getByRole('combobox', { name: 'Browser' }), { target: { value: 'chrome' } }); + fireEvent.change(screen.getByLabelText('What do you want to test?'), { target: { value: task.source.goal } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Start Quick Test' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Start Quick Test' })); + await screen.findByText('Preparing'); + let complete!: (value: { workspaces: WorkspaceRegistryEntry[] }) => void; + vi.mocked(controlPlaneClient.workspaces).mockImplementationOnce(() => new Promise(resolve => { complete = resolve; })); + fireEvent.click(screen.getByRole('link', { name: 'Back to start' })); + fireEvent.click(screen.getByRole('button', { name: 'FSQ Case Manager' })); + expect(screen.getByRole('button', { name: 'Start exploration' })).toBeDisabled(); + const after: WorkspaceRegistryEntry = { + ...workspace, + platforms: validity === 'removed' ? [] : validity === 'unavailable' ? [{ ...workspace.platforms[0], status: 'unavailable' }] : workspace.platforms, + }; + await act(async () => complete({ workspaces: [after, { ...workspace, name: 'quick-test' }] })); + expect(screen.getByLabelText('Workspace')).toHaveValue(workspace.name); + expect(screen.getByRole('option', { name: 'quick-test' })).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue('Keep Manager goal'); + if (validity === 'valid') { + await waitFor(() => expect(screen.getByRole('combobox', { name: 'Platform' })).toHaveValue('web')); + await waitFor(() => expect(screen.getByRole('combobox', { name: 'Browser' })).toHaveValue('chrome-beta')); + expect(screen.getByRole('button', { name: 'Start exploration' })).toBeDisabled(); + const refresh = screen.getByRole('button', { name: 'Refresh readiness, targets, and cases' }); + await waitFor(() => expect(refresh).toBeEnabled()); + busy = false; + fireEvent.click(refresh); + await waitFor(() => expect(screen.getByRole('button', { name: 'Start exploration' })).toBeEnabled()); + expect(screen.getByRole('combobox', { name: 'Browser' })).toHaveValue('chrome-beta'); + } else { + expect(screen.getByRole('combobox', { name: 'Platform' })).toHaveValue(''); + expect(screen.getByRole('combobox', { name: 'Target' })).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Start exploration' })).toBeDisabled(); + } + expect(controlPlaneClient.startQuickTest).toHaveBeenCalledTimes(1); + expect(controlPlaneClient.startRun).not.toHaveBeenCalled(); + expect(controlPlaneClient.cancelRun).not.toHaveBeenCalled(); +}); diff --git a/frontend/control-plane/src/app/EntryApp.test.tsx b/frontend/control-plane/src/app/EntryApp.test.tsx new file mode 100644 index 00000000..7d0a5112 --- /dev/null +++ b/frontend/control-plane/src/app/EntryApp.test.tsx @@ -0,0 +1,76 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { useState, type MutableRefObject } from 'react'; +import { EntryApp } from './EntryApp'; +import { controlPlaneClient } from '../api/controlPlaneClient'; + +vi.mock('./ControlPlaneApp', () => ({ ControlPlaneApp: ({ entryGuard, onBackToStart }: { entryGuard: MutableRefObject<() => boolean>; onBackToStart: () => void }) => { + const [workspace, setWorkspace] = useState(''); + const [pending, setPending] = useState(false); + entryGuard.current = () => !pending; + return
Manager content setWorkspace(event.target.value)} />
; +} })); +vi.mock('../features/config/ConfigPage', () => ({ ConfigPage: ({ onDirtyChange, onSavePendingChange, onSaveUncertainChange }: { onDirtyChange: (dirty: boolean) => void; onSavePendingChange: (pending: boolean) => void; onSaveUncertainChange: (uncertain: boolean) => void }) => +
, +})); +beforeEach(() => { + window.history.replaceState(null, '', '/'); + vi.spyOn(controlPlaneClient, 'bootstrap').mockResolvedValue({ apiVersion: '1', platforms: [], busy: false, activeTask: null }); +}); +afterEach(() => vi.restoreAllMocks()); +it('offers equal entries then exact Quick navigation with a quiet return link', async () => { + render(); + expect(screen.getByRole('button', { name: 'Quick Test' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'FSQ Case Manager' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Quick Test' })); + const sidebar = screen.getByRole('complementary', { name: 'Control Plane sidebar' }); + expect(within(sidebar).getByRole('button', { name: 'Quick Test' })).toBeInTheDocument(); + expect(within(sidebar).getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + expect(within(sidebar).queryByText('Workspaces')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Back to start' })).toBeInTheDocument(); +}); +it('routes old #runs links directly to Case Manager', () => { + window.history.replaceState(null, '', '/#runs'); + render(); + expect(screen.getByText('Manager content')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Quick Test' })).not.toBeInTheDocument(); +}); +it('keeps the idle prompt mounted through shared Settings and honors dirty/pending/unknown guards', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Quick Test' })); + fireEvent.change(screen.getByLabelText('What do you want to test?'), { target: { value: 'Check Notes' } }); + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + expect(screen.getByRole('region', { name: 'Shared ConfigPage' })).toBeVisible(); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + fireEvent.click(screen.getByRole('button', { name: 'Edit Provider' })); + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + fireEvent.click(screen.getByRole('link', { name: 'Back to start' })); + expect(window.location.hash).toBe('#quick-test/settings'); + expect(confirm).toHaveBeenCalledWith('Discard unsaved Provider changes?'); + confirm.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Save pending' })); + fireEvent.click(screen.getByRole('link', { name: 'Back to start' })); + expect(confirm).not.toHaveBeenCalled(); + act(() => { window.history.pushState(null, '', '#start'); window.dispatchEvent(new PopStateEvent('popstate')); }); + expect(window.location.hash).toBe('#quick-test/settings'); + fireEvent.click(screen.getByRole('button', { name: 'Save unknown' })); + fireEvent.click(screen.getByRole('button', { name: 'Quick Test' })); + expect(confirm).toHaveBeenCalledWith('The save result is unknown. Leaving does not cancel it. Continue?'); + confirm.mockReturnValue(true); + fireEvent.click(screen.getByRole('button', { name: 'Quick Test' })); + expect(screen.getByLabelText('What do you want to test?')).toHaveValue('Check Notes'); + expect(screen.queryByRole('region', { name: 'Shared ConfigPage' })).not.toBeInTheDocument(); +}); +it('keeps independent Manager state mounted across entry switches and respects its pending guard', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'FSQ Case Manager' })); + fireEvent.change(screen.getByLabelText('Manager workspace'), { target: { value: 'my-workspace' } }); + fireEvent.click(screen.getByRole('button', { name: 'Toggle manager pending' })); + fireEvent.click(screen.getByRole('button', { name: 'Manager back' })); + expect(screen.getByText('Manager content')).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Toggle manager pending' })); + fireEvent.click(screen.getByRole('button', { name: 'Manager back' })); + fireEvent.click(screen.getByRole('button', { name: 'Quick Test' })); + fireEvent.click(screen.getByRole('link', { name: 'Back to start' })); + fireEvent.click(screen.getByRole('button', { name: 'FSQ Case Manager' })); + await waitFor(() => expect(screen.getByLabelText('Manager workspace')).toHaveValue('my-workspace')); +}); diff --git a/frontend/control-plane/src/app/EntryApp.tsx b/frontend/control-plane/src/app/EntryApp.tsx new file mode 100644 index 00000000..eba05c82 --- /dev/null +++ b/frontend/control-plane/src/app/EntryApp.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react'; +import { ControlPlaneApp } from './ControlPlaneApp'; +import { ControlPlaneShell } from './shell/ControlPlaneShell'; +import { ConfigPage } from '../features/config/ConfigPage'; +import { QuickTestPage } from '../features/quick-test/QuickTestPage'; +import { parseRunRoute, runHash } from '../features/runs/runRoute'; +import './entry.css'; + +type Entry = 'start' | 'quick-test' | 'case-manager'; +function entryForHash(hash: string): Entry { + if (parseRunRoute(hash) || hash === '#case-manager') return 'case-manager'; + if (hash === '#quick-test' || hash === '#quick-test/settings') return 'quick-test'; + return 'start'; +} +function entryForLocation(): Entry { + if (!window.location.hash && window.history.state?.cpEntry === 'case-manager') return 'case-manager'; + return entryForHash(window.location.hash); +} + +export function EntryApp() { + const [entry, setEntry] = useState(entryForLocation); + const [settings, setSettings] = useState(() => window.location.hash === '#quick-test/settings'); + const [visited, setVisited] = useState(() => new Set([entry])); + const [dirty, setDirty] = useState(false); + const [pending, setPending] = useState(false); + const [uncertain, setUncertain] = useState(false); + const managerGuard = useRef(() => true); + const acceptedLocation = useRef({ href: window.location.href, state: window.history.state }); + const state = useRef({ entry, settings, dirty, pending, uncertain }); + state.current = { entry, settings, dirty, pending, uncertain }; + + const canLeave = (destination: Entry, nextSettings: boolean) => { + const current = state.current; + if (current.entry === 'case-manager' && destination !== 'case-manager') return managerGuard.current(); + if (current.entry !== 'quick-test' || !current.settings || (destination === 'quick-test' && nextSettings)) return true; + if (current.pending) return false; + if (current.uncertain && !window.confirm('The save result is unknown. Leaving does not cancel it. Continue?')) return false; + if (current.dirty && !window.confirm('Discard unsaved Provider changes?')) return false; + return true; + }; + const accept = (destination: Entry, nextSettings: boolean) => { + setEntry(destination); + setSettings(nextSettings); + setVisited(previous => new Set([...previous, destination])); + if (destination !== 'quick-test' || !nextSettings) { setDirty(false); setUncertain(false); } + acceptedLocation.current = { href: window.location.href, state: window.history.state }; + }; + const navigate = (hash: string) => { + const destination = entryForHash(hash); + const nextSettings = hash === '#quick-test/settings'; + if (!canLeave(destination, nextSettings)) return; + window.history.pushState({ cpEntry: destination }, '', hash); + accept(destination, nextSettings); + }; + const locationHandler = useRef(() => {}); + locationHandler.current = () => { + if (window.location.href === acceptedLocation.current.href && window.history.state?.cpEntry === acceptedLocation.current.state?.cpEntry) return; + const hash = window.location.hash; + const destination = entryForLocation(); + // Only positively identified Manager history entries belong to its page router. + if (state.current.entry === 'case-manager' && destination === 'case-manager') { + acceptedLocation.current = { href: window.location.href, state: window.history.state }; + return; + } + const nextSettings = hash === '#quick-test/settings'; + if (!canLeave(destination, nextSettings)) { + const previous = acceptedLocation.current; + window.history.replaceState(previous.state, '', previous.href); + return; + } + accept(destination, nextSettings); + }; + useEffect(() => { + window.history.replaceState({ ...window.history.state, cpEntry: state.current.entry }, '', window.location.href); + acceptedLocation.current = { href: window.location.href, state: window.history.state }; + const handler = () => locationHandler.current(); + window.addEventListener('hashchange', handler); + window.addEventListener('popstate', handler); + return () => { window.removeEventListener('hashchange', handler); window.removeEventListener('popstate', handler); }; + }, []); + return <> + {entry === 'start' &&

FSQ

Choose how you want to test.

+

Quick Test

Natural-language AI exploration without Workspace setup.

+

FSQ Case Manager

Complete Workspace and Case management, exploration, strict replay, and Run inspection.

+
} + {visited.has('case-manager') && } + {visited.has('quick-test') && } + ; +} diff --git a/frontend/control-plane/src/app/entry.css b/frontend/control-plane/src/app/entry.css new file mode 100644 index 00000000..464496f4 --- /dev/null +++ b/frontend/control-plane/src/app/entry.css @@ -0,0 +1,14 @@ +.entry-chooser { max-width: 960px; margin: 0 auto; padding: clamp(24px, 8vw, 100px) 24px; } +.entry-chooser h1 { margin: 0; font-size: var(--cp-type-page); line-height: var(--cp-leading-page); font-weight: 600; } +.entry-chooser h2 { margin: 0; font-size: var(--cp-type-dialog); line-height: var(--cp-leading-dialog); font-weight: 600; } +.entry-chooser p { color: var(--cp-muted); line-height: var(--cp-leading-body); } +.entry-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin-top: 32px; } +.entry-card { display: flex; flex-direction: column; align-items: start; gap: 12px; padding: 28px; border: 1px solid var(--cp-border); border-radius: var(--cp-card-radius); background: var(--cp-surface); } +.entry-card p { flex: 1; margin: 0; } +.entry-card .button { margin-top: 12px; width: 100%; } +.cp-back-to-start { margin-left: auto; flex-shrink: 0; color: var(--cp-muted); font-size: var(--cp-type-label); padding: 8px; } +.cp-titlebar:has(.cp-back-to-start) { flex-wrap: wrap; } +@media (max-width: 600px) { + .entry-cards { grid-template-columns: minmax(0, 1fr); } + .entry-card { padding: 20px; } +} diff --git a/frontend/control-plane/src/app/shell/ControlPlaneShell.test.tsx b/frontend/control-plane/src/app/shell/ControlPlaneShell.test.tsx index d9c152c1..2c318245 100644 --- a/frontend/control-plane/src/app/shell/ControlPlaneShell.test.tsx +++ b/frontend/control-plane/src/app/shell/ControlPlaneShell.test.tsx @@ -3,6 +3,17 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ControlPlaneShell } from './ControlPlaneShell'; +it('scopes shell landmark and drawer ids when both entries are mounted', () => { + render(<> +
Manager
+
Quick
+ ); + const mains = screen.getAllByRole('main'); + expect(mains[0].id).not.toBe(mains[1].id); + const menus = screen.getAllByRole('button', { name: 'Open navigation' }); + expect(menus[0].getAttribute('aria-controls')).not.toBe(menus[1].getAttribute('aria-controls')); +}); + it('renders the specified navigation with one Settings entry and available Runs', () => { render(
Arbitrary page outlet
); const sidebar=screen.getByLabelText('Control Plane sidebar'); diff --git a/frontend/control-plane/src/app/shell/ControlPlaneShell.tsx b/frontend/control-plane/src/app/shell/ControlPlaneShell.tsx index 5f9bb292..9e7c77cd 100644 --- a/frontend/control-plane/src/app/shell/ControlPlaneShell.tsx +++ b/frontend/control-plane/src/app/shell/ControlPlaneShell.tsx @@ -1,10 +1,12 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; import { Menu, X } from 'lucide-react'; import { ControlPlaneSidebar } from './ControlPlaneSidebar'; -import { CONTROL_PLANE_NAVIGATION, type ControlPlanePageId, type WorkspaceNavigationItem } from './navigation'; +import { CONTROL_PLANE_NAVIGATION, QUICK_TEST_NAVIGATION, type ControlPlanePageId, type WorkspaceNavigationItem } from './navigation'; import './shell.css'; interface ControlPlaneShellProps { + variant?: 'case-manager' | 'quick-test'; + onBackToStart?: () => void; activePage: ControlPlanePageId; title: string; description: string; @@ -24,8 +26,9 @@ interface ControlPlaneShellProps { interactionLocked?: boolean; } -export function ControlPlaneShell({ activePage, title, description, outletPresentation = 'default', titleContent, titleActions, children, workspaces, selectedWorkspaceId, workspaceRegistryStatus, workspaceRegistryError, onNavigate, onRetryWorkspaces, onCreateWorkspace, onSelectWorkspace, onDiagnoseWorkspace, interactionLocked }: ControlPlaneShellProps) { +export function ControlPlaneShell({ variant = 'case-manager', onBackToStart, activePage, title, description, outletPresentation = 'default', titleContent, titleActions, children, workspaces, selectedWorkspaceId, workspaceRegistryStatus, workspaceRegistryError, onNavigate, onRetryWorkspaces, onCreateWorkspace, onSelectWorkspace, onDiagnoseWorkspace, interactionLocked }: ControlPlaneShellProps) { const [drawerOpen, setDrawerOpen] = useState(false); + const sidebarId = useId(); const [narrow, setNarrow] = useState(false); const toggleRef = useRef(null); const drawerRef = useRef(null); @@ -87,16 +90,16 @@ export function ControlPlaneShell({ activePage, title, description, outletPresen type="button" aria-label="Open navigation" aria-expanded={drawerOpen} - aria-controls="control-plane-sidebar" + aria-controls={sidebarId} onClick={() => setDrawerOpen(true)} >