diff --git a/CLAUDE.md b/CLAUDE.md index c15979b2..112cfcf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ Web-based Robot Framework test management (Git, GUI execution, reports, environm - **Recorder capture script MUST use `composedPath()[0]`, not `ev.target`**: events inside an open shadow root surface with `ev.target` retargeted to the *host*. `recording/capture_script.py::realTarget(ev)` returns `ev.composedPath()[0]` (falls back to `ev.target` for closed roots); every handler (click, dblclick, change, keydown, dragstart, drop) routes through it. The ancestor walk crosses shadow boundaries via `crossShadow(el)` (jump to `getRootNode().host` when `parentElement` is null); each ancestor's `is_shadow_host` flag makes synthesis emit a `host >> inner` chained locator (`selector_synthesis.py::_shadow_chain`). Pinned by `test_capture_script.py::TestShadowDomAwareness`. New event listeners must route through `realTarget`. - **Recorder selector synthesis MUST emit a parent-context CSS variant**: `selector_synthesis.py::_css` emits ` tag.classes` whenever a stable ancestor exists, not just `tag.classes`. A bare `button.submit-btn` matching every submit is the top Playwright strict-mode failure source at replay; pinning the nearest stable-id ancestor (quality_score `+10`) cuts misfires by orders of magnitude. `_with_nth_match` (`selector_verification.py`) is the last-resort fallback (penalty `-15`). Pinned by `test_selector_synthesis.py::TestParentContextCss`. - **Online-dist `.bat` files MUST be CRLF; the Windows online installer MUST pass `--find-links wheels`**: `scripts/build-online-mac-and-linux.sh` generates `install/start/stop-windows.bat` via Bash heredocs (→ LF line endings) on the Linux CI host. cmd.exe mis-parses LF-only batch files — it loses the first token of each line, runs `::` comments as commands, and never reaches `uv venv`, so the install silently no-ops and ships no `.venv`. A trailing awk pass normalises every `*.bat` to CRLF; **any new `.bat` added to that script must go through the same loop** or it ships broken on Windows. Separately, the bundle vendors the `robotframework-roboscopeheal` wheel in `wheels/` (not on PyPI), so the generated `install-windows.bat` must install with `--find-links wheels` (mirror the mac/linux branch) or uv 404s on roboheal. The *offline* `build-windows.ps1` is immune to both: PowerShell `Set-Content` writes CRLF and it already uses `--no-index --find-links=wheels`. Both failure modes are pinned by **Gate 6/7 in `.github/workflows/phase4-gates.yml`** (build online dist on Linux → run the real `install-windows.bat` on `windows-latest` → assert `import RoboScopeHeal` + `/health` boot). Regression history: missing `--find-links` hit main 2026-06-01 (PR #48) and the LF breakage was caught by the new gate 2026-06-15 (PR #49). +- **Deployment feature flags (Epic GOV) resolve ENV > DB > default-ON; enforcement is per-endpoint, NOT in middleware**: `src/governance/flags.py::resolve_flag` resolves a flag from `ROBOSCOPE_FEATURE_` (e.g. `ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT`) → `app_settings` row `features.` (category `features`, seeded) → registry default (ON). The ENV value wins and marks the flag `locked` (the Settings UI disables that toggle — `SettingsView.vue::settingLocked`). To govern an endpoint, add `Depends(require_package_op(""))` (or `require_feature`) from `src/governance/dependencies.py` — the flag gate is **absolute** (403s even for ADMIN when off) and runs BEFORE the configurable role floor (`features.packageManagement.role.`, default EDITOR). **Trap: the audit middleware skips responses with status ≥ 400** (`audit/middleware.py`), so a 403 raised from a dependency is NOT auto-logged — `require_package_op` therefore writes its own `AuditLog` (`action="blocked"`, detail `feature_disabled:…` / `insufficient_role:…`) and `db.commit()`s before raising. Any new governed endpoint that must be audited has to do the same. Frontend gate: `useFeatureFlags()` (singleton, token-guarded like `useBypassStatus` to avoid the 401-redirect loop; default-enabled while loading since the server is authoritative). Pinned by `tests/governance/` + `e2e/tests/gov-feature-lockdown.spec.ts`. ## Release diff --git a/_bmad-output/planning-artifacts/aix-prd.md b/_bmad-output/planning-artifacts/aix-prd.md new file mode 100644 index 00000000..c5e02075 --- /dev/null +++ b/_bmad-output/planning-artifacts/aix-prd.md @@ -0,0 +1,44 @@ +# Epic AIX — AI Provider & Output Enhancements — PRD + Design + +**Status**: Planning → ready for implementation +**Date**: 2026-06-18 +**PM/Architect**: John / Winston +**Parent**: [presentation-feedback-epics.md](./presentation-feedback-epics.md) +**Epic**: AIX + +## What already exists + +- `call_llm` (`ai/llm_client.py`) routes every non-`anthropic` provider through `_call_openai_compatible`, honoring `provider.api_base_url`. So an OpenAI-compatible **LiteLLM gateway** already works today via a generic provider with `api_base_url` set — it just isn't offered as a labeled choice. +- The analysis already threads a `language` param (request → `dispatch_task(run_analyze, job.id, language)` → prompt directive). Verbosity mirrors this exactly. + +## AIX-1 — LiteLLM provider type + +**JTBD**: *"As an admin, I want to point RoboScope at our LiteLLM gateway so I can use any model we proxy and centralize keys/spend."* + +- FE: add `{ value: 'litellm', label: 'LiteLLM (Gateway)' }` to `ProviderConfig.vue` provider types; freeform model list; empty default model; a hint that **the base URL is the gateway endpoint and is required**. +- BE: `litellm` already resolves to `_call_openai_compatible`. Add a guard: if `provider_type == 'litellm'` and no `api_base_url`, the call raises a clear error (no silent fallback to api.openai.com). +- i18n: `ai.litellmHint` in EN/DE/FR/ES/ZH. + +## AIX-2 — Analysis verbosity control + +**JTBD**: *"As a user, I want a concise summary or a deep dive on demand, so the analysis fits the moment."* + +- `concise | standard | detailed` → a prompt directive (appended to `SYSTEM_PROMPT_ANALYZE`, like the language directive) and an effective `max_tokens` cap (concise ≈ 600, standard = provider default, detailed = provider default). Default `standard`. +- Plumbing mirrors `language`: `AnalyzeRequest.verbosity` → `dispatch_task(run_analyze, job.id, language, verbosity)` → `verbosity_directive(verbosity)` composed into the system prompt. Frontend sends the chosen verbosity (a small select near the Analyze button; default standard). +- i18n: `reportDetail.analysis.verbosity.*` in all 5 locales. + +## Functional requirements + +- **FR-1** LiteLLM is selectable; configuring it with a base URL + model produces working analyses; without a base URL it fails with a clear error (not a wrong-endpoint call). +- **FR-2** Verbosity is selectable per analysis (default standard); `concise` yields a materially shorter prompt directive + lower max_tokens; code/keywords/patches stay verbatim; composes with the language directive. +- **FR-3** Non-breaking: existing providers + analyses behave exactly as before when verbosity is unset (treated as standard). + +## Acceptance + +1. `verbosity_directive` unit-pinned (concise/standard/detailed/None); analyze dispatch passes verbosity through. +2. LiteLLM provider type appears in the form and round-trips; backend guard unit-pinned (litellm + no base_url → error). +3. i18n complete in EN/DE/FR/ES/ZH (Gate 8 stays green). +4. Real-UI E2E: the analysis card shows a verbosity selector. + +## Handoff +→ Implementation (Amelia) → review → E2E. diff --git a/_bmad-output/planning-artifacts/exec-prd.md b/_bmad-output/planning-artifacts/exec-prd.md new file mode 100644 index 00000000..635f3b35 --- /dev/null +++ b/_bmad-output/planning-artifacts/exec-prd.md @@ -0,0 +1,38 @@ +# Epic EXEC — RF Execution Configuration — PRD + scope + +**Status**: EXEC-1/EXEC-3 shipped; EXEC-2/4/5/6/7 deferred (backlog) +**Date**: 2026-06-18 +**PM/Architect**: John / Winston +**Parent**: [presentation-feedback-epics.md](./presentation-feedback-epics.md) + +## What already existed (discovery) + +The execution pipeline already modeled and applied tags + variables: +`RunCreate`/`ExecutionRun` carry `tags_include`, `tags_exclude`, `variables`, and +`subprocess_runner.py` (and the Docker runner) already build +`robot --include/--exclude/--variable` from them. **But the run dialog never +exposed them** — so users couldn't actually use the capability. That was the gap. + +## Shipped this iteration + +- **EXEC-1 / EXEC-3 — tag selection in the run dialog**: the New Run dialog now has **Include tags** and **Exclude tags** inputs (comma-separated), threaded through `runForm` → `RunCreateRequest` (already typed) → the existing backend that converts them to `robot --include/--exclude`. i18n in EN/DE/FR/ES/ZH. Real-UI E2E (`e2e/tests/run-tags.spec.ts`) asserts the create-run request carries `tags_include`; the runner application is covered by existing backend tests. + +This is the migration-free, backend-ready slice that delivers the most-requested +"manage what robot runs" capability (tag-based selection) end to end. + +## Deferred to backlog (each its own future cycle) + +These are larger initiatives, intentionally not built in this iteration: + +- **EXEC-1b — free-form `robot` args + `--variable` UI**: a guarded "advanced args" field (and a variables key/value editor) needs a new `ExecutionRun` column + Alembic migration + runner arg-merge + injection-safe parsing. (Variables are already modeled/applied; only the UI is missing.) +- **EXEC-2 — PreRunModifiers** (`--prerunmodifier`): config + project-provided module resolution; security note (arbitrary code in the env). +- **EXEC-4 — Long Name / unique ID surfacing → Jira association** (feeds the Phase-6 Jira plugin). +- **EXEC-5 — `__init__.robot`** suite-init editing in Explorer/Flow. +- **EXEC-6 — DataDriver / dynamic test generation** (spike-first). +- **EXEC-7 — RF best-practices research spike** → UI-surfacing backlog (RF Certified Professional rubric). + +## Acceptance (shipped scope) + +1. The run dialog exposes Include/Exclude tags; submitting a run sends `tags_include`/`tags_exclude` — pinned by real-UI E2E. +2. No regression to the existing runner tag/variable handling (existing backend tests green). +3. i18n complete in all 5 locales (Gate 8 green). diff --git a/_bmad-output/planning-artifacts/gov-architecture.md b/_bmad-output/planning-artifacts/gov-architecture.md new file mode 100644 index 00000000..fae2bc4a --- /dev/null +++ b/_bmad-output/planning-artifacts/gov-architecture.md @@ -0,0 +1,120 @@ +# Epic GOV — Architecture + +**Status**: Planning → ready for implementation +**Date**: 2026-06-18 +**Architect**: Winston +**Parent**: [gov-prd.md](./gov-prd.md) + +## Guiding principle + +Boring technology. We already have a typed key-value `app_settings` table (`settings/service.py::get_setting_value`) and an ordered-role `require_role(min_role)` dependency. The whole epic is a thin resolver + two FastAPI dependencies + one read endpoint + one frontend composable. No new framework, no new table, no migration. + +## 1. Flag registry & resolver + +New module `backend/src/governance/flags.py`: + +``` +FEATURE_FLAGS: dict[str, bool] = { + "packageManagement": True, # GOV-2: install/uninstall/upgrade/build/rfbrowser-init +} +# Per-op role floor (GOV-4); resolved separately, only consulted when the area is ON. +PACKAGE_OP_ROLE_DEFAULT = Role.EDITOR # matches today's behavior +``` + +**Resolution precedence (per flag): ENV > DB > default.** +- ENV: `ROBOSCOPE_FEATURE_` (e.g. `packageManagement` → `ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT`). Parsed as bool (`1/true/yes/on` → true, `0/false/no/off` → false; anything else ignored). Read once at process start into a frozen dict. +- DB: `app_settings` row, `category="features"`, `key=`, `value_type="bool"`. Read via `get_setting_value`. +- default: `FEATURE_FLAGS[flag]` (ON). + +``` +def resolve_flag(db, key) -> ResolvedFlag # {value: bool, locked: bool} +def resolve_all(db) -> dict[str, ResolvedFlag] +``` +`locked=True` when the ENV override is set (the UI renders it as "managed by your administrator", non-editable). DB-or-default → `locked=False`. + +**Caching:** none in v1. `app_settings` is tiny and reads are indexed; a per-request lookup on a mutating env call is negligible. (If a hot path ever needs it, add a TTL cache invalidated in `update_settings` — noted, not built.) + +**Seeding:** extend `seed_default_settings` with the `features` category rows so they appear in the Settings UI as editable toggles (value defaults to the registry default). Idempotent — existing installs get the row on next boot, value ON, behavior unchanged. + +## 2. Backend enforcement dependency + +New `backend/src/governance/dependencies.py`: + +``` +def require_feature(flag: str): + def dep(db = Depends(get_db)): + if not resolve_flag(db, flag).value: + raise HTTPException(403, detail="feature_disabled:") + return dep + +def require_package_op(op: str): + """Compose flag gate + configurable role floor for a package operation.""" + def dep(db = Depends(get_db), user = Depends(get_current_user)) -> User: + if not resolve_flag(db, "packageManagement").value: + raise HTTPException(403, detail="feature_disabled:packageManagement") # absolute — even ADMIN + floor = resolve_package_op_role(db, op) # settings key features.packageManagement.role., default EDITOR + if ROLE_RANK[user.role] < ROLE_RANK[floor]: + raise HTTPException(403, detail="insufficient_role") + return user + return dep +``` + +**Wiring (GOV-2 + GOV-4):** on the mutating env endpoints (`install_package` L529, `upgrade_package` L572, `retry_package_install` L612, `uninstall_package` L694, `docker_build` L264, `rfbrowser-init` L654), replace `Depends(require_role(Role.EDITOR))` with `Depends(require_package_op(""))`. Read endpoints (list/installed/keywords/dockerfile/search/popular) are untouched → stay 200 in locked mode (FR-3). `create`/`clone`/`delete` **environment** are governance-neutral for v1 (the customer concern is *package* mutation on a managed env) — left on `require_role` as today; revisit if needed. + +**Order matters:** flag check first (absolute policy), role floor second (permission). A locked deployment 403s before any role consideration. + +**Audit (FR-6):** the existing audit middleware already logs every POST/PUT/PATCH/DELETE with user/IP and the response — a 403'd mutation is captured. We add the `feature_disabled` / `insufficient_role` detail string so blocked attempts are greppable; no new audit pipeline. + +## 3. Read contract — `GET /config/features` + +New tiny router `backend/src/governance/router.py`, mounted on `api_router` → `/api/v1/config/features`: + +``` +GET /config/features (auth: any logged-in user) +→ { "flags": { "packageManagement": true }, + "locked": { "packageManagement": false } } # locked=true ⇒ set via ENV, UI shows non-editable +``` + +Admin **editing** flags needs no new endpoint — flags are `app_settings` rows, edited through the existing Settings update path (PUT). GOV-1 only adds the seed rows + this read endpoint + the resolver. + +## 4. Frontend — `useFeatureFlags()` composable + +`frontend/src/composables/useFeatureFlags.ts` — singleton, mirrors `useUserSettings`/`useBypassStatus` pattern: + +- **MUST early-return when `localStorage.getItem('access_token')` is null** (CLAUDE.md redirect-loop gotcha) — it's consumed by global layout. +- Fetches `/config/features` once, caches; exposes `isEnabled(flag): boolean` (default true while loading, so nothing flickers hidden) and `isLocked(flag): boolean`. +- Refetch hook after login + after an admin saves settings. + +**Consumption (GOV-2 + GOV-3):** `EnvironmentsView` / package components gate every mutating control (`+ Install`, uninstall ✕, upgrade, Docker build, rfbrowser-init) on `isEnabled('packageManagement')`. When disabled: controls are hidden, and a localized banner renders — "Package management is managed by your administrator" (EN/DE/FR/ES). The package list, versions, and Docker image stay visible (read-only). **GOV-3 is this locked-state UX, not a second flag** — read-only environments == `packageManagement` off. (Simplification vs. PRD's separate sub-mode; one flag is cleaner and covers the requirement.) + +## 5. File layout + +``` +backend/src/governance/ + __init__.py + flags.py # registry + resolver (env/db/default) + dependencies.py # require_feature, require_package_op + router.py # GET /config/features + schemas.py # FeaturesResponse +backend/tests/governance/ + test_flags.py # precedence unit tests + test_package_lockdown.py # every mutating env endpoint → 403 when off; role floor +frontend/src/composables/useFeatureFlags.ts +frontend/src/tests/composables/useFeatureFlags.spec.ts +``` + +## 6. Test strategy (feeds the E2E story) + +- **Unit (backend):** precedence (env beats db beats default; bool parsing; locked flag); `require_package_op` 403 paths (flag off → 403 even for ADMIN; role floor below → 403). +- **Endpoint:** parametrized test hitting every mutating env endpoint with flag OFF → 403, and a read endpoint → 200. +- **Unit (frontend):** composable token-guard (no fetch without token), isEnabled/isLocked. +- **E2E (real UI, Playwright):** boot backend with `ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT=false`, log in, open Environments → assert no install/uninstall/build controls, banner visible, package list still rendered; assert a direct API mutation returns 403. Second run with flag unset → controls present (default unchanged). + +## 7. Risks / decisions + +- **Env read timing:** env flags frozen at process start — changing the env var requires a restart (correct for a deployment-level lock; documented). +- **`create/clone/delete environment` left ungoverned in v1** — scoped to package mutation per the customer's actual concern; trivially extendable by adding `require_feature` later. +- **No new migration** — `app_settings` rows are seeded, not schema'd. + +## 8. Handoff +→ **Implementation (Amelia):** GOV-1 (flags module + resolver + `/config/features` + seed + `useFeatureFlags`), then GOV-2 (wire `require_package_op` + UI gating + banner i18n), GOV-3 (locked-state UX polish), GOV-4 (role-floor settings + resolution). Then code review + full UI E2E. diff --git a/_bmad-output/planning-artifacts/gov-prd.md b/_bmad-output/planning-artifacts/gov-prd.md new file mode 100644 index 00000000..79624e39 --- /dev/null +++ b/_bmad-output/planning-artifacts/gov-prd.md @@ -0,0 +1,83 @@ +# Epic GOV — Deployment Governance & Feature Lockdown — PRD + +**Status**: Planning → ready for architecture +**Date**: 2026-06-18 +**Owner / PM**: John +**Parent**: [presentation-feedback-epics.md](./presentation-feedback-epics.md) · connects into [prd.md](./prd.md) +**Epic**: GOV + +## 1. Problem & evidence + +On a shared or remote RoboScope install, **any** authenticated user with a sufficient role can mutate the *server's* Python environments — install/uninstall/upgrade packages, trigger Docker builds, run `rfbrowser init`. At Provinzial, environment provisioning is owned by a central administration team; end users mutating a managed environment is a governance violation and a concrete deployment blocker (raised directly at the 2026-06-17 presentation). + +Role gating alone doesn't solve it: the customer wants the capability **gone** for the whole deployment, not merely restricted to higher roles — and they want it enforced server-side, not just hidden in the UI. + +## 2. Users & Jobs-to-be-Done + +- **Operator / Administrator** (installs & governs RoboScope): *"When I deploy RoboScope for a team whose environments I manage centrally, I want to switch off package management entirely, so no end user can change a managed environment."* +- **End user (RUNNER/EDITOR)** on a locked-down install: *"When a capability is governed away, I want a clear, non-broken UI that explains it's managed by my admin — not a button that 500s."* + +## 3. Goals / Non-goals + +**Goals** +- A deployment-level switch to disable feature areas, starting with **package management**. +- Enforcement **server-side** (API returns 403), not just UI hiding. +- A read-only environments mode (see, don't touch). +- Configurable minimum role for package operations where they remain enabled. +- Non-breaking: existing installs behave exactly as today unless an operator opts in. + +**Non-goals (v1)** +- Per-project (vs. per-deployment) flags — global only for v1. +- A general policy engine / OPA — a small explicit flag set. +- Disabling arbitrary features beyond the defined set (extensible, but v1 ships the package-management area only). + +## 4. Product decisions (open questions resolved) + +1. **Scope = global per-deployment.** Flags apply install-wide. (Per-project deferred until a customer needs it.) +2. **Source of truth & precedence:** `ENV var > app_settings(DB) > built-in default`. The env var is the operator's hard lock (wins over any in-app admin toggle) — critical for managed/remote installs where the admin team owns the host, not the app. +3. **Default = enabled.** Unknown/unset flags resolve to ON, so upgrades never silently remove features. +4. **Lockdown is absolute when off:** even an ADMIN cannot install when the `packageManagement` flag is OFF (it's a deployment policy, not a permission). The *role floor* (decision 5) only applies when the area is ON. +5. **Role floor** for package ops is a separate, optional setting (default = today's behavior) for installs that keep package management ON but want to restrict who uses it. + +## 5. Functional requirements + +- **FR-1** A resolved feature-flag set is exposed read-only to the frontend (`GET /config/features`) and consumed by a `useFeatureFlags()` composable that gates UI affordances. +- **FR-2** Flags resolve with precedence env > DB(`app_settings`, category `features`) > default(ON). Admins can edit DB flags in Settings; the env override is visible-but-locked in the UI when set. +- **FR-3** With `packageManagement` OFF: the install/uninstall/upgrade/retry endpoints, `docker-build`, and `rfbrowser-init` return **403**; read endpoints (list packages, keyword cache, dockerfile preview) stay 200. +- **FR-4** With `packageManagement` OFF (or read-only mode ON): the Environments UI hides/disables all mutating controls and shows a localized "managed by your administrator" notice (EN/DE/FR/ES). +- **FR-5** A configurable **role floor** per mutating op (install/uninstall/upgrade/docker-build); enforced in the existing `require_role` path; default preserves current minimum roles. +- **FR-6** Every blocked attempt (403 by flag or role floor) is written to the audit log with user/IP/op. + +## 6. Non-functional requirements + +- **NFR-1 Security:** server-side enforcement is authoritative; UI hiding is convenience only. No flag may be bypassed via direct API or API-token calls. +- **NFR-2 Non-breaking:** default-on; a 0.10.x install upgrading sees identical behavior until an operator sets a flag. +- **NFR-3 Discoverability:** flags + precedence documented in in-app docs (EN/DE/FR/ES), GitHub Pages, and a CLAUDE.md gotcha; new audit codes documented. +- **NFR-4 Offline:** no external calls introduced (RoboScope offline-only invariant). + +## 7. Stories (this epic) — see parent epics doc for full text + +- **GOV-1** Feature-flag foundation (resolver + `GET /config/features` + `useFeatureFlags()`). +- **GOV-2** Lock package management (UI hide + endpoint 403 behind the flag). +- **GOV-3** Read-only Environments mode. +- **GOV-4** Configurable role floor for package ops. + +Build order: GOV-1 → GOV-2 → GOV-3 → GOV-4 (each independently shippable). + +## 8. Success metrics + +- An operator can fully lock package management via a single env var, verified by an automated test that asserts 403 on every mutating env endpoint. +- Zero regressions for default (flag-unset) installs (existing env e2e + unit suites stay green). +- Provinzial-style acceptance: a RUNNER/EDITOR on a locked install sees a coherent read-only Environments page with no dead buttons. + +## 9. Acceptance (epic-level) + +1. With `packageManagement` OFF there is **no** mutation path (UI, REST, API token) — pinned by backend test hitting each endpoint → 403. +2. `GET /config/features` reflects env-over-DB-over-default precedence — pinned by unit test. +3. Locked UI is coherent + localized on every affected surface — pinned by a real-UI Playwright e2e (Environments page in locked mode). +4. Default install unchanged — full existing suites green. +5. Docs (in-app 4-lang + Pages) + CLAUDE.md gotcha + audit codes updated (Gate 8 stays green). + +## 10. Handoff + +→ **Architecture (Winston):** flag resolver design (env/DB/default precedence, caching, where it lives), the `/config/features` contract, how `require_role` composes with the flag check, and the frontend gating composable. Then → implementation (Amelia) story-by-story → code review → full UI E2E. diff --git a/_bmad-output/planning-artifacts/presentation-feedback-epics.md b/_bmad-output/planning-artifacts/presentation-feedback-epics.md new file mode 100644 index 00000000..0800098a --- /dev/null +++ b/_bmad-output/planning-artifacts/presentation-feedback-epics.md @@ -0,0 +1,180 @@ +# Presentation Feedback (2026-06-17) — Epics + Stories + +**Status**: Planning +**Date**: 2026-06-18 +**Owner**: RoboScope core team +**Analyst**: Mary +**Source**: Field notes from the 2026-06-17 presentation (Provinzial, Daniel, team Q&A) + +## Headline + +Five raw feedback items from real users group cleanly into **four epics**: + +1. **GOV — Deployment Governance & Feature Lockdown** — let operators disable feature areas (first: package management) per deployment, so end users on a managed/remote install can't (de)install packages. +2. **RES — Repository Resource Files** — make local/repo `.resource` keywords first-class (discover, autocomplete, execute). +3. **EXEC — Robot Framework Execution Configuration** — surface RF's real execution levers (CLI args, PreRunModifiers, tagging, `__init__.robot`, dynamic data) in the UI. +4. **AIX — AI Provider & Output Enhancements** — LiteLLM gateway + analysis verbosity control. + +## Traceability — raw note → epic/story + +| Raw note | Maps to | +|---|---| +| Disabling einzelner Komponenten (z.B. "Python Pakete verwalten" bei Provinzial); remote-managed, Endnutzer dürfen nicht (de)installieren | GOV-1, GOV-2, GOV-3, GOV-4 | +| Verbosity der KI-Analyse begrenzen | AIX-2 | +| (Daniel) Lokale/Repository Resources einlad- und nutzbar machen | RES-1..RES-4 | +| PreRunModifiers + Suite-Tagging | EXEC-2, EXEC-3 | +| robot.exe-Parameter übergebbar/verwaltbar | EXEC-1 | +| Eindeutige ID / "Long Name" → Jira-Assoziation | EXEC-4 | +| Inhalte in `__init__.robot` | EXEC-5 | +| DataDriver / dynamisch (z.B. aus Jira) Testfälle erzeugen | EXEC-6 | +| RF-"Magic" / Best-Practices aus der Doku in die UI (RF Certified Professional) | EXEC-7 | +| LiteLLM-Anbindung | AIX-1 | + +--- + +## Epic GOV — Deployment Governance & Feature Lockdown + +### Why this, why now +At Provinzial, Python environment installation is owned by a central administration team — and on a shared/remote RoboScope install, an end user clicking "uninstall package" mutates the **server's** environment for everyone. Today every authenticated user with the right role can install/uninstall/upgrade packages and trigger Docker builds (`environments/router.py`). Operators need a hard, deployment-level switch to take these affordances away, independent of user roles. This is a concrete deployment blocker for a named enterprise prospect. + +### Stories + +**GOV-1 — Feature-flag foundation** +As an operator, I want a server-side configuration that turns whole feature areas on/off, so I can tailor an install to my organization's governance. +- Flags resolved server-side from `app_settings` (admin-editable) with an **env-var override** that wins (for locked-down/remote installs the admin team controls); precedence documented. +- `GET /config/features` returns the resolved flag set; a frontend `useFeatureFlags()` composable gates UI. +- Unknown flags default to **enabled** (no silent feature loss on upgrade). Flag changes are audit-logged. + +**GOV-2 — Lock package management** +As an operator, I want to disable package install/uninstall/upgrade and Docker build, so end users can't mutate managed environments. +- Behind a `packageManagement` flag: UI hides the install/uninstall/upgrade/build/retry/rfbrowser-init actions; the matching `environments` endpoints return **403** when off (defense in depth — UI hiding is not enough). +- Read paths (list packages, keyword cache) stay available so the rest of the app still works. +- Blocked attempts are audit-logged with user/IP. + +**GOV-3 — Read-only Environments mode** +As an operator, I want a "view-only" environments mode, so users can see what's installed without changing it. +- Sub-mode of GOV-2: packages + versions + Docker image visible; all mutating controls disabled with a clear "managed by your administrator" hint (i18n EN/DE/FR/ES). + +**GOV-4 — Configurable role floor for package ops** +As an operator who still allows package ops, I want to set the **minimum role** that may perform them, so only ADMINs (or a chosen role) can. +- Per-operation min-role setting (install / uninstall / upgrade / docker-build); enforced in the same dependency the endpoints already use. +- Defaults preserve today's behavior (no breaking change for existing installs). + +### Epic acceptance gates +1. With the flag OFF, there is **no path** (UI, direct API, API token) to mutate an environment — pinned by a backend test that calls each endpoint and asserts 403. +2. Flags are discoverable and documented (CLAUDE.md gotcha + in-app docs + GitHub Pages); default-on so 0.10.x → next is non-breaking. +3. The "disabled" UX is consistent and localized across every affected surface. + +### Non-goals +- Per-project (vs. per-deployment) feature flags — start global; revisit if a customer needs it. +- A full policy engine — this is a small, explicit flag set, not OPA. + +--- + +## Epic RES — Repository Resource Files (.resource) + +### Why this, why now +Mature RF teams keep shared keywords in `.resource` files imported via `Resource ../common.resource`. RoboScope's keyword discovery today is libdoc-per-environment + partial project-keyword detection (`useKeywordSignatures.ts` order: project keywords > libdoc(env) > bootstrap). Daniel's ask: make **repo resource files** properly loadable and usable — discovery, autocomplete, and reliable execution. + +### Stories + +**RES-1 — Index repository `.resource` files** +As a test author, I want keywords defined in my repo's `.resource` files to be discovered, so I can use them like any other keyword. +- Parse `.resource` (and `.robot` keyword sections) in the project tree into a keyword index with names + signatures + source path. +- Refresh on file change / sync; cache keyed per repo. + +**RES-2 — Resolve `Resource` imports for autocomplete** +As a test author, I want the editor + flow palette to suggest keywords from `Resource`-imported files, so I don't have to remember signatures. +- Follow the `Resource` import graph (transitively, with cycle guard) from the open file; surface those keywords in the palette under a "Project Resources" category. +- Signature resolution order stays RF-faithful: **project keywords > resources > libdoc(env) > bootstrap**. + +**RES-3 — Resource keyword detail (signatures + docs)** +As a test author, I want argument hints and `[Documentation]` for resource keywords, so the detail panel and doc modal work for them too. + +**RES-4 — Execution honors resource imports (verify + pin)** +As a test author, I want a run to resolve `Resource` imports identically across subprocess and Docker runners, so what autocompletes also runs. +- Verify current behavior; add a pinned e2e (a test that calls a resource keyword) for both runner types. + +### Non-goals +- Authoring new `.resource` files via a dedicated wizard — out of scope; the existing file-create + editor suffices for v1. + +--- + +## Epic EXEC — Robot Framework Execution Configuration + +### Why this, why now +The biggest cluster of feedback is "let me drive RF the way I actually run it." Today the run dialog exposes target path, timeout, environment. Power users need the real `robot` levers: argument pass-through, PreRunModifiers, tagging, suite-init, and dynamic data generation. Several of these also unlock the Phase-6 Jira plugin (unique IDs / Long Name). EXEC-7 (research) should run **first** — it sharpens the scope of EXEC-1..6. + +### Stories + +**EXEC-1 — Manage `robot` CLI arguments per run / schedule** +As a power user, I want to pass and save `robot` arguments, so I can control selection and variables without leaving RoboScope. +- Curated, validated arg surface: `--include/--exclude` (tags), `--variable`, `--variablefile`, `--name`, `--settag`, `--rerunfailed`, plus a guarded free-text "advanced args" field with an allowlist + injection-safe construction. +- Args persist on the run config and on Schedules; echoed into the run record for reproducibility. + +**EXEC-2 — PreRunModifier support** +As a power user, I want to apply `--prerunmodifier`s, so I can transform the suite before execution (tag injection, filtering, dynamic test creation). +- Configure one or more prerun modifiers (built-in helpers + project-provided module path); resolved in the run's environment. +- Documented security note: modifiers run arbitrary code in the env (same trust boundary as the tests themselves). + +**EXEC-3 — Suite/test tag management in the UI** +As a test author, I want to view and edit tags and run "by tag", so I can organize and select tests. +- Surface tags in Explorer/Flow editor; "Run by tag" wired to EXEC-1's `--include/--exclude`. + +**EXEC-4 — Unique test ID / Long Name surfacing (Jira foundation)** +As a QA lead, I want each test's stable Long Name / unique ID exposed, so I can associate it with a Jira issue. +- Compute + display the RF **Long Name** (`Suite.Sub.Test`); allow a stable external-ID mapping (tag convention or metadata). Lays groundwork for the Phase-6 Jira plugin — no Jira API in this story. + +**EXEC-5 — `__init__.robot` (suite initialization) support** +As a test author, I want to edit folder-level suite setup/teardown/metadata, so suite-init behaves correctly in the UI. +- Explorer recognizes `__init__.robot`; Flow/Visual editor edits Suite Setup/Teardown/Metadata/Documentation at the directory level; round-trips faithfully. + +**EXEC-6 — DataDriver / dynamic test generation (SPIKE → feature)** +As an advanced user, I want data-driven tests generated at runtime (e.g. from a data file or Jira), so I don't hand-write repetitive cases. +- **Spike first**: evaluate `robotframework-datadriver` + the PreRunModifier path (EXEC-2) as the generation mechanism; produce a feasibility note before committing the feature. + +**EXEC-7 — RF best-practices research → UI backlog (SPIKE)** +As the product team, I want RF's documented best practices and "magic" (filename rules, `__init__`, tagging, selection, the RF Certified Professional syllabus) distilled into concrete UI surfacings, so we guide users toward correct usage. +- Output: a prioritized backlog of small UI improvements (warnings, hints, defaults) feeding EXEC-1..6 and beyond. **Do this first in the epic.** + +### Non-goals +- A full RF CLI passthrough (every flag) — curate to what's safe + meaningful; advanced free-text is the escape hatch. +- Building a Jira integration in EXEC-4 — that's the Phase-6 plugin; EXEC-4 only exposes the ID surface. + +--- + +## Epic AIX — AI Provider & Output Enhancements + +### Why this, why now +Two small, high-leverage AI asks. The provider layer (`ai/llm_client.py`) already speaks OpenAI-compatible for openai/openrouter/ollama, so LiteLLM (an OpenAI-compatible gateway) is a thin addition. Verbosity control rides the prompt/`max_tokens` plumbing the just-shipped localized analysis already touches. + +### Stories + +**AIX-1 — LiteLLM provider type** +As an admin, I want to point RoboScope at a LiteLLM gateway, so I can use any model my org proxies (and centralize keys/spend). +- New `litellm` provider type reusing `_call_openai_compatible` with a configurable `base_url` + model passthrough; key handling matches existing providers (Fernet-encrypted). i18n + provider-form entry; one happy-path test against a mocked endpoint. + +**AIX-2 — AI analysis verbosity control** +As a user, I want to choose how detailed the failure analysis is, so I get a tight summary or a deep dive on demand. +- Verbosity setting (e.g. `concise` / `standard` / `detailed`) mapped to prompt guidance + `max_tokens`; selectable per analysis with a default in settings. Composes with the existing language directive. + +### Non-goals +- Streaming AI output — separate concern. +- Per-provider model auto-discovery — out of scope for AIX-1. + +--- + +## Sequencing recommendation + +| Wave | Stories | Rationale | +|---|---|---| +| **1 — Quick wins / unblockers** | GOV-1, GOV-2, GOV-3 · AIX-2 · EXEC-7 (spike) | GOV unblocks a named prospect; AIX-2 is cheap; EXEC-7 sharpens the whole EXEC epic before we build it. | +| **2 — Core enablers** | GOV-4 · RES-1, RES-2 · AIX-1 · EXEC-1 | Resource discovery + CLI args are broadly useful; LiteLLM is small. | +| **3 — Depth** | RES-3, RES-4 · EXEC-2, EXEC-3, EXEC-5 | Build on wave-2 foundations. | +| **4 — Bigger bets** | EXEC-4 (→ Phase-6 Jira) · EXEC-6 (DataDriver) | Larger / dependent on spikes + Jira plugin. | + +## Open questions for the team +1. **GOV scope**: global (per-deployment) flags only for v1, or do any customers need per-project disabling? +2. **EXEC-1 arg surface**: which exact `robot` flags are in-scope for the curated UI vs. the advanced free-text field? +3. **Jira (EXEC-4)**: is the unique-ID convention a tag (`jira:PROJ-123`) or RF metadata — and does this wait for the Phase-6 Jira plugin or lead it? +4. **DataDriver (EXEC-6)**: which real customer workflow drives this (the "generate from Jira tickets" case)? That defines the spike's success criteria. diff --git a/_bmad-output/planning-artifacts/res-architecture.md b/_bmad-output/planning-artifacts/res-architecture.md new file mode 100644 index 00000000..b3dc4b6a --- /dev/null +++ b/_bmad-output/planning-artifacts/res-architecture.md @@ -0,0 +1,61 @@ +# Epic RES — Architecture + +**Status**: Planning → ready for implementation +**Date**: 2026-06-18 +**Architect**: Winston +**Parent**: [res-prd.md](./res-prd.md) + +## Guiding principle + +Reuse, don't rebuild. `FlowEditor.vue::addLibrary(name)` already routes a name +ending in `.resource` or containing `/` to a `Resource` import (and dedupes, +and emits `libraries-changed`). `addNodeFromPalette(step, library)` already +calls `addLibrary(library)` when `library` is set. The entire fix is: **make +the palette pass the resource file path as `library` for project keywords that +live in a different file than the one being edited.** + +## 1. Data we have + +- `ProjectKeyword = { name, file_path, arguments }` (`api/explorer.api.ts`), `file_path` repo-relative (e.g. `resources/common.resource`). +- `KeywordPalette.vue` already has `props.filePath` (the open file, repo-relative) and `projectKeywords.value`. +- Project keywords are grouped into `Project: ` categories, with the open file's category flagged `isCurrentFile`. + +## 2. Changes + +### a. Path util — `frontend/src/components/editor/flow/resourcePath.ts` +``` +resourceImportPath(openFile: string, resourceFile: string): string +``` +Both inputs repo-relative. Returns the path from the open file's **directory** +to `resourceFile`, POSIX separators (e.g. open `tests/login.robot`, resource +`resources/common.resource` → `../resources/common.resource`; same dir → +`common.resource`). Pure, unit-testable. (Mirrors RF's "Resource paths are +relative to the importing file".) + +### b. Palette — thread the source path +- Build a `Map` name → `file_path` from `projectKeywords` (when a name appears in multiple files, last wins — acceptable v1; the realistic case is unique keyword names across resources). +- Add a handler `addProjectKeyword(name)` used by the project-category items: if the keyword's source `file_path !== props.filePath`, compute `resourceImportPath(props.filePath, file_path)` and emit it as the `library` arg of `add-node`; otherwise emit no library (same-file keyword = already local). +- BuiltIn / Library / Control keyword paths are unchanged (`libraryHintFor` still returns the lib name or undefined). The current-file project category passes no import. + +### c. FlowEditor / RobotEditor — no change +`addNodeFromPalette` → `addLibrary(path)` already produces `Resource ` +in `*** Settings ***`, deduped, and triggers a palette refresh. The text +serializer already round-trips `Resource` imports (existing `robotTextIO` +contract). + +## 3. Guards (FR-3) +- Source file == open file → no import (local keyword). +- BuiltIn / Library / Control keyword → existing behavior (lib name or none), never a Resource path. +- Dedupe is `addLibrary`'s existing responsibility. + +## 4. Risks / decisions +- **Duplicate keyword names across resources**: the name→path map collapses them; v1 picks one. Low real-world risk; note it. (A future refinement could carry `file_path` on the palette item itself.) +- **Drag-and-drop path**: `makeStepFromDrag` doesn't carry a library/resource. v1 covers the click + "+" add path (the primary insert flow, same one GOV/control-structures use). Drag auto-import is a follow-up (noted). + +## 5. Test strategy +- **Unit (util)**: `resourcePath.spec.ts` — same dir, parent dir, nested, identical file, Windows-style input tolerance. +- **Unit (palette/flow)**: a `.resource` keyword insert adds the `Resource` import; a same-file/BuiltIn/Library insert does NOT. +- **E2E (real UI)**: seed a repo with `resources/common.resource` (defining a keyword) + a test file; open the test in the Flow editor, insert the resource keyword from the palette, Save, and assert the saved `.robot` contains `Resource ../resources/common.resource` and the keyword call. + +## 6. Handoff +→ **Implementation (Amelia)**: the util + palette threading + tests. Then review + E2E. diff --git a/_bmad-output/planning-artifacts/res-prd.md b/_bmad-output/planning-artifacts/res-prd.md new file mode 100644 index 00000000..ca7037ec --- /dev/null +++ b/_bmad-output/planning-artifacts/res-prd.md @@ -0,0 +1,64 @@ +# Epic RES — Repository Resource Files — PRD + +**Status**: Planning → ready for architecture +**Date**: 2026-06-18 +**Owner / PM**: John +**Parent**: [presentation-feedback-epics.md](./presentation-feedback-epics.md) +**Epic**: RES + +## 1. What already exists (do NOT rebuild) + +- **Discovery**: `backend/src/explorer/service.py::parse_robot_keywords_in_repo` extracts every user-defined keyword (name + `[Arguments]`) from all `.robot`/`.resource` files in a repo. Exposed at `GET /explorer/{repo_id}/keywords`. +- **Surfacing**: `KeywordPalette.vue` loads them via `getProjectKeywords(repoId)`, groups them under `Project: ` categories, and lets the user insert them. +- **Signatures**: `useKeywordSignatures.ts` resolves project-keyword signatures with the RF-faithful order *project > libdoc(env) > bootstrap*. + +So a `.resource` keyword is already **discoverable, searchable, and insertable**. RES-1 and RES-3 are effectively shipped. + +## 2. The gap (the real problem) + +When the user inserts a keyword that comes from a **`.resource` file**, RoboScope does **not** add the corresponding `Resource ` import to the file's `*** Settings ***` — the code explicitly opts out (`KeywordPalette.vue:191-194`). Library keywords auto-import their `Library X`; resource keywords do not. + +Result: the inserted keyword **fails at runtime** — `No keyword with name '…' found` — because the resource was never imported. The user has to know to add `Resource …/foo.resource` by hand. That is exactly Daniel's "make local/repository Resources loadable *and usable*": the loading works, the *usability* (run-without-manual-fixup) does not. + +## 3. Users & JTBD + +- **Test author** (EDITOR): *"When I drop a keyword from a shared `.resource` file into my test, I want RoboScope to wire up the `Resource` import for me, so the test actually runs without me hand-editing Settings."* + +## 4. Goals / Non-goals + +**Goals** +- Inserting a project keyword sourced from a `.resource` (or another `.robot`) file auto-adds `Resource ` to the open file's settings, if not already present — mirroring the Library auto-import. +- The path is RF-valid (relative, forward slashes) and de-duplicated. +- Verify a run resolves the import (RES-4) and pin it. + +**Non-goals (v1)** +- Import-graph scoping of the palette (showing only keywords reachable from the open file's existing imports) — the current "show all repo keywords" behavior is a convenience, not a bug; out of scope. +- A `*** Variables ***`-from-resource importer — variables aren't the reported pain; defer. +- Resolving keywords across repos / external resource paths. + +## 5. Functional requirements + +- **FR-1** When a keyword whose source file is a `.resource`/`.robot` (and not the currently-open file) is added from the palette, add `Resource ` to the form's resource imports if absent. +- **FR-2** The path is computed relative to the open file (or repo-root-relative, whichever the existing Resource-import convention uses), normalized to forward slashes; adding the same resource twice is a no-op. +- **FR-3** Adding a keyword from the **same** file, a BuiltIn, or a Library keyword does NOT add a Resource import (no false imports). +- **FR-4** The auto-added import round-trips through the `.robot` text serializer unchanged (RES-4: a run resolves the keyword). + +## 6. Stories + +- **RES-2** Auto-import `Resource` on keyword insert (the core fix). FE: palette passes the source path; FlowEditor/RobotEditor adds the Resource import. +- **RES-4** Verify + pin execution honors the auto-added import (unit round-trip + real-UI E2E that inserts a resource keyword, saves, and confirms the `Resource` line is present in the saved `.robot`). + +## 7. Success metrics + +- A test author can insert a `.resource` keyword and the saved `.robot` contains a matching `Resource` import — verified by E2E. No manual Settings edit required. +- Zero false imports for BuiltIn/Library/same-file keywords (unit-pinned). + +## 8. Acceptance (epic-level) + +1. Inserting a `.resource` keyword adds exactly one correct, de-duplicated `Resource` import; pinned by unit + real-UI E2E. +2. No regression to Library auto-import or to BuiltIn/same-file inserts. +3. i18n unaffected (no new user-facing strings expected; if a toast is added, EN/DE/FR/ES/ZH). + +## 9. Handoff + +→ **Architecture (Winston)**: where the source path is threaded from `getProjectKeywords` → palette → `add-node` → FlowEditor; how `Resource` imports are represented in `RobotForm`/settings and how `addLibrary` works (to mirror it as `addResource`); path computation + dedupe. Then impl (Amelia) → review → E2E. diff --git a/_bmad-output/planning-artifacts/ux-flow-editor-resources.md b/_bmad-output/planning-artifacts/ux-flow-editor-resources.md new file mode 100644 index 00000000..49adb4ae --- /dev/null +++ b/_bmad-output/planning-artifacts/ux-flow-editor-resources.md @@ -0,0 +1,223 @@ +--- +workflowType: 'ux-design' +status: 'ready' +scope: 'flow-editor-keyword-palette-and-resources' +project_name: 'roboscope' +author: 'Sally (UX) + Thomas' +date: '2026-06-18' +relatedArtifacts: + - res-prd.md + - res-architecture.md +note: 'Scoped spec — does NOT supersede ux-design-specification.md (Phase 4).' +--- + +# UX Design — Flow Editor Keyword Palette & Custom Resources + +## The story we're fixing + +> Thomas keeps his team's shared keywords in `resources/common.resource`. In the +> Flow Editor he opens `tests/login.robot`, finds **Open Login Page** in the +> palette, clicks **+**. The node drops onto the canvas — and three things happen +> in silence: "Libraries (0)" ticks to "(1)", a `Resource` import is written into +> Settings, and the detail panel opens with an **empty** "+ add argument" even +> though the keyword takes `${url}`. It *works*. But Thomas can't *see* that it +> wired the import, his own keyword is buried as one of 18 look-alike categories, +> the long name is clipped with no tooltip, and he's re-typing an argument the +> tool already knows. The magic isn't trustworthy, and his own code doesn't feel +> first-class. + +The goal: make **using your own repository keywords feel direct, legible, and +trustworthy** — the import visible, the signature respected, the palette yours +to shape. + +## Current-state findings (observed live, 2026-06-18) + +| # | Finding | Evidence | +|---|---------|----------| +| F1 | **Silent auto-import** — inserting a `.resource` keyword writes the `Resource` import with no acknowledgement; the only signal is the "Libraries" counter. | `FlowEditor.addLibrary` emits `libraries-changed` but no toast/inline confirmation. | +| F2 | **Resource conflated with Library** — `.resource` imports live in the panel labelled "Libraries"; RF distinguishes `Library` vs `Resource`. | `form.settings` Library+Resource share the "Bibliotheken" panel. | +| F3 | **Arguments not pre-filled** — the inserted resource-keyword node shows empty "+ add argument" despite a known `[Arguments] ${url}` signature; Library keywords *do* get arg slots. | Palette shows "Open Login Page (1)"; detail panel shows no `${url}` slot. | +| F4 | **Project keywords aren't first-class** — `PROJECT: COMMON.RESOURCE` (shouty, basename only, no path, no file/resource icon) sits as one of ~18 categories. | Palette category list. | +| F5 | **Long names break down** — `.palette-item-name` is `ellipsis/nowrap` in a 220px rail with **no `title` tooltip**; descriptive resource names get clipped unrecoverably. | `.palette-item-name` CSS; 220px `.keyword-palette`. | +| F6 | **No sort control** — categories are usage-then-fallback ordered; the user can't choose (e.g. alphabetical, project-first). | `libCats.sort(...usage...)` hardcoded. | +| F7 | **No "what's shown" filter** — non-installed libraries are always offered as "examples"; the user can't hide them to see only what's imported / their own. | `_ALWAYS_VISIBLE_LIBS` + `isExamples` always rendered. | + +## Design directions + +### D1 — Resources are first-class & distinct (F2, F4) + +Split the palette's project/resource grouping out of the generic library list and +give it its own, top-pinned section with a file glyph and the **relative path** +as a subtitle. Separate the imports panel into **Resources** and **Libraries**. + +``` +┌─ Keywords ───────────────────[⇅][▼ filter]─┐ +│ [ search… ] │ +│ │ +│ ▾ YOUR RESOURCES │ ← own section, pinned, not SHOUTY +│ 📄 common.resource · resources/ │ ← path subtitle, file glyph +│ Open Login Page (1) │ +│ Submit Credentials │ +│ 📄 keywords.resource · resources/shared/ │ +│ … │ +│ ────────────────────────────────────────── │ +│ ▸ BuiltIn 537 │ +│ ▸ Browser [+ lib] 144 │ +│ … │ +└─────────────────────────────────────────────┘ + +Settings panel (was "Libraries (1)"): + Resources (1) ▸ resources/common.resource [↗ open] [×] + Libraries (0) ▸ — +``` + +**AC-D1** +- Project/resource keywords render in a dedicated, top-pinned "Your resources" + group, grouped by file, each file showing its repo-relative directory as a + subtitle and a file glyph (resource vs `.robot`). +- The Settings/imports panel shows **Resources** and **Libraries** as separate + labelled lists; a `.resource` import never appears under "Libraries". +- No SHOUTY all-caps for the user's own files. + +### D2 — Visible import acknowledgement (F1) + +When inserting a keyword auto-adds a `Resource` (or `Library`) import, surface it. + +``` + ┌─────────────────────────────────────────────┐ + │ ✓ Imported resources/common.resource │ + │ so "Open Login Page" resolves at runtime. │ + └─────────────────────────────────────────────┘ (toast, 4s, undo-able) +``` + +Plus an inline one-time pulse on the new **Resources (1)** badge. + +**AC-D2** *(decided: plain toast, no Undo)* +- Adding an import via keyword insert shows a localized toast naming the file + (EN/DE/FR/ES/ZH) — *only* when an import was actually added (not on dedupe). +- No Undo affordance (kept deliberately simple — the import is visible in the + Resources panel and removable there). +- No toast for BuiltIn / same-file / already-imported inserts. + +### D3 — Respect the known signature (F3) + +A project/resource keyword carries `[Arguments]` (the parser already extracts +them). Pre-fill the node's argument slots exactly like a Library keyword. + +``` +KEYWORD Open Login Page +ARGUMENTS + url [ ${BASE_URL} ] ← pre-seeded slot from [Arguments] ${url} + [+ add argument] +``` + +**AC-D3** +- Inserting a project/resource keyword pre-creates one argument slot per declared + `[Arguments]` entry, labelled with the arg name, defaults shown where present. +- Round-trips to `.robot` correctly (positional/named). + +### D4 — Long names stay legible (F5) + +``` +│ Open Login Page And Wait For … (2) │ ← clip + ALWAYS a title= tooltip +│ └ hover ─► "Open Login Page And Wait For Dashboard" +``` + +**AC-D4** +- Every palette item carries a `title` (full keyword name) so a clipped name is + recoverable on hover. +- The arg-count / "+ lib" badges never wrap or overlap the name; name flexes, + badges stay pinned right. +- Consider a 2-line clamp for the user's own resource keywords (they trend long). + +### D5 — Sort control (F6) + +A compact ⇅ control in the palette header: **Most used** (default) · **A–Z** · +**Project first**. Persisted per user (localStorage). + +**AC-D5**: selecting a sort reorders categories live; choice persists across reloads; +"Your resources" stays pinned above libraries regardless (or honors "A–Z" if chosen). + +### D6 — "What's shown" filter (F7) — *adaptive default* + +A ▼ filter button: checkboxes for **Your resources**, **Imported libraries**, +**Example libraries (not installed)**, **BuiltIn**. A subtle count shows when a +filter is hiding things. + +``` +[▼ Filter] + ☑ Your resources + ☑ Imported libraries + ☐ Example libraries (not installed) ← Thomas's case: hide the noise + ☑ BuiltIn + "Hiding 12 example libraries · clear" +``` + +**The default is adaptive** (Thomas's call, 2026-06-18): the palette guesses +whether the user is in *discovery mode* or *building a real test* and chooses the +starting filter accordingly, so a beginner sees the rich example-library catalogue +while someone working a real suite isn't drowned in not-installed noise. + +| Situation | Default filter | Rationale | +|-----------|----------------|-----------| +| Repo has **no environment** yet | **Everything** (incl. example libs) | Pure discovery — the example libs are the menu of "what's possible". | +| Repo has an env **and** the open file is a *mini / fresh* file | **Everything** | Still exploring; don't prematurely hide options. | +| Repo has an env **and** the open file is *sophisticated* | **Imported-only** (Your resources + Imported libraries + BuiltIn; example libs hidden) | The user knows their stack; surface what actually resolves at runtime. | + +**"Sophisticated" heuristic** (cheap, client-side, no backend call): the open file +counts as sophisticated when it **already imports ≥1 Library/Resource** OR has +**≥ ~5 steps** across its test cases/keywords. Otherwise it's "mini/fresh". (The +two thresholds live in one place so they're tunable; pin them in a unit test.) + +**AC-D6** +- Initial filter state is derived from the table above on first open of a file + (env presence + the sophisticated heuristic); no flicker (decide before first + paint of the category list). +- A **manual override persists** (localStorage) and wins over the adaptive default + for that user thereafter — the heuristic only seeds the *first* experience. +- Toggles filter the visible categories live; an always-visible hint + ("Hiding N example libraries · clear") whenever categories are hidden, so + nothing feels "missing" by accident. +- Example libraries are never *removed* from discoverability — hiding is reversible + in one click and announced. + +## Priority + +| Wave | Items | Why | +|------|-------|-----| +| **1 — Quick wins** | D2 (import toast), D3 (arg pre-fill), D4 (title tooltip) | Small, high-trust; D3 removes real re-typing; all low-risk. | +| **2 — Structure** | D1 (resources first-class + split panel), D6 (filter) | Medium; reshapes the palette mental model. | +| **3 — Polish** | D5 (sort), D1 path/preview | Nice-to-have refinements. | + +## Decisions (locked 2026-06-18, with Thomas) + +These resolve the former open questions; the ACs above already reflect them. + +1. **D1 — separate pinned section.** Resources get their own top-pinned "Your + resources" group (file glyph + relative-path subtitle), and the Settings panel + splits into distinct **Resources** and **Libraries** lists. *Not* a mere + relabel of "Libraries → Imports". +2. **D2 — plain toast, no Undo.** A localized confirmation toast only when an + import was actually added; no Undo affordance (removal lives in the Resources + panel). +3. **D6 — adaptive imported-only default.** For repos *with* an environment, a + *sophisticated* open file defaults to imported-only (example libs hidden); + fresh/mini files and env-less repos default to showing everything for + discovery. A manual override persists and wins thereafter. (Heuristic: + ≥1 import OR ≥~5 steps ⇒ sophisticated.) + +## Implementation status (all waves shipped 2026-06-18) + +All six directions are implemented, unit-tested, type-checked, prod-built, and +covered by real-UI E2E. + +| Item | What landed | Where | +|------|-------------|-------| +| **D1** | Pinned "Your resources" palette section (file glyph + relative-path subtitle, no SHOUTY) via a `kind` discriminator; imports panel split into labelled **Resources** / **Libraries** lists. | `KeywordPalette.vue`, `FlowEditor.vue` (`resourceImportEntries`/`libraryImportEntries`), i18n `resourcesSectionLabel`/`resourcePanelLabel`/`librariesPanelLabel` | +| **D2** | `addLibrary` returns the kind it added (or `false`); insert + drag paths fire a localized import toast only when an import was actually added. No Undo. | `FlowEditor.vue` (`notifyImportAdded`), i18n `importAdded.*` | +| **D3** | Inserting a keyword pre-seeds one empty slot per **required** positional arg from its signature. | `FlowEditor.vue` (`prefillRequiredArgs`), `FlowEditorPrefillArgs.spec.ts` | +| **D4** | Every palette item carries a `title` (full name); name flexes, badges/argcount pinned right. | `KeywordPalette.vue` | +| **D5** | Header sort control (Most used / A–Z / Imported first), persisted in localStorage; resources stay in their pinned section. | `paletteView.ts::sortLibraries`, i18n `sort.*` | +| **D6** | Filter dropdown (resources / imported libs / example libs / BuiltIn) with the **adaptive default** (env + sophisticated heuristic ≥1 import OR ≥5 steps) and a persisted manual override + "{n} hidden · show all" affordance. | `paletteView.ts` (`adaptiveDefaultFilter`, `isSophisticatedFile`, `applyFilter`, `hiddenCount`), i18n `filter.*` | + +**Tests** — `PaletteView.spec.ts` (heuristic/filter/sort/persistence), `FlowEditorPrefillArgs.spec.ts` (D3), `e2e/tests/flow-editor-resource-ux.spec.ts` (D1/D2/D3/D5/D6 real UI). Full vitest suite 851 green; prod build clean (no i18n escaping break); adjacent flow-editor E2E (12) + RES autoimport green. diff --git a/backend/src/ai/llm_client.py b/backend/src/ai/llm_client.py index f70aee68..e17b3f53 100644 --- a/backend/src/ai/llm_client.py +++ b/backend/src/ai/llm_client.py @@ -43,6 +43,11 @@ def _call_openai_compatible( import httpx base_url = provider.api_base_url + if not base_url and provider.provider_type == "litellm": + raise ValueError( + "LiteLLM provider requires a Base URL (the gateway endpoint, " + "e.g. http://litellm.internal:4000)." + ) if not base_url: if provider.provider_type == "openrouter": base_url = "https://openrouter.ai/api/v1" diff --git a/backend/src/ai/prompts.py b/backend/src/ai/prompts.py index 74796fb5..cffca90f 100644 --- a/backend/src/ai/prompts.py +++ b/backend/src/ai/prompts.py @@ -172,6 +172,27 @@ def language_directive(locale: str | None) -> str: ) +def verbosity_directive(verbosity: str | None) -> str: + """A system-prompt suffix tuning analysis length. `standard`/None → "". + + Composes with `language_directive`; code/keywords/patches are unaffected. + """ + if verbosity == "concise": + return ( + "\n\n## Length\n" + "Be concise: a 2-3 sentence executive summary and only the highest-" + "priority root cause(s) and fix(es). Skip exhaustive per-test detail. " + "Still include a unified-diff patch when you are confident." + ) + if verbosity == "detailed": + return ( + "\n\n## Length\n" + "Be thorough: per-failure root-cause analysis, cross-failure patterns, " + "step-by-step fixes, and prioritized actions." + ) + return "" # standard / unknown / None → model's default + + def build_analyze_user_prompt( report_summary: dict, failed_tests: list[dict], diff --git a/backend/src/ai/router.py b/backend/src/ai/router.py index ca922911..eefc7f25 100644 --- a/backend/src/ai/router.py +++ b/backend/src/ai/router.py @@ -265,7 +265,7 @@ def analyze_failures( db.commit() try: - dispatch_task(run_analyze, job.id, data.language) + dispatch_task(run_analyze, job.id, data.language, data.verbosity) except TaskDispatchError as e: job.status = "failed" job.error_message = str(e) diff --git a/backend/src/ai/schemas.py b/backend/src/ai/schemas.py index a802db2e..1af663bd 100644 --- a/backend/src/ai/schemas.py +++ b/backend/src/ai/schemas.py @@ -80,6 +80,7 @@ class AnalyzeRequest(BaseModel): report_id: int provider_id: int | None = None # None = use default provider language: str | None = None # frontend i18n locale (de/en/fr/es/zh); None = English + verbosity: str | None = None # concise | standard | detailed; None = standard class ApplyPatchRequest(BaseModel): diff --git a/backend/src/ai/tasks.py b/backend/src/ai/tasks.py index 01dd08ae..83611f4b 100644 --- a/backend/src/ai/tasks.py +++ b/backend/src/ai/tasks.py @@ -20,6 +20,7 @@ build_reverse_user_prompt, enrich_generate_prompt, language_directive, + verbosity_directive, ) from src.database import get_sync_session from src.repos.models import Repository @@ -329,11 +330,12 @@ async def _gather_analysis_knowledge(failed_tests: list[dict]) -> list[dict]: return keyword_docs -def run_analyze(job_id: int, language: str | None = None) -> None: +def run_analyze(job_id: int, language: str | None = None, verbosity: str | None = None) -> None: """Background task: analyze test failures in a report. ``language`` is the frontend i18n locale (de/en/fr/es/zh); the analysis prose is produced in that language while code/patches stay verbatim. + ``verbosity`` (concise|standard|detailed) tunes the analysis length. """ with get_sync_session() as session: job = session.execute(select(AiJob).where(AiJob.id == job_id)).scalar_one_or_none() @@ -406,7 +408,11 @@ def run_analyze(job_id: int, language: str | None = None) -> None: + docs_text ) - system_prompt = SYSTEM_PROMPT_ANALYZE + language_directive(language) + system_prompt = ( + SYSTEM_PROMPT_ANALYZE + + language_directive(language) + + verbosity_directive(verbosity) + ) result = call_llm(provider, system_prompt, user_prompt) job.result_preview = result.content diff --git a/backend/src/api/v1/router.py b/backend/src/api/v1/router.py index e60a3221..aa3fb7c5 100644 --- a/backend/src/api/v1/router.py +++ b/backend/src/api/v1/router.py @@ -11,6 +11,7 @@ from src.environments.router import router as environments_router from src.execution.router import router as execution_router from src.explorer.router import router as explorer_router +from src.governance.router import router as governance_router from src.recording.router import router as recording_router from src.reports.router import router as reports_router from src.repos.router import router as repos_router @@ -34,6 +35,7 @@ api_router.include_router(reports_router, prefix="/reports", tags=["Reports"]) api_router.include_router(stats_router, prefix="/stats", tags=["Statistics"]) api_router.include_router(settings_router, prefix="/settings", tags=["Settings"]) +api_router.include_router(governance_router, prefix="/config", tags=["Governance"]) api_router.include_router(ai_router, prefix="/ai", tags=["AI Generation"]) api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks & Tokens"]) api_router.include_router(recording_router, tags=["Recording"]) diff --git a/backend/src/environments/router.py b/backend/src/environments/router.py index 4383ab1b..7b467909 100644 --- a/backend/src/environments/router.py +++ b/backend/src/environments/router.py @@ -11,6 +11,7 @@ from src.auth.dependencies import get_current_user, require_role from src.auth.models import User from src.database import get_db +from src.governance.dependencies import require_package_op from src.environments.models import Environment, EnvironmentKeywordCache, EnvironmentPackage from src.environments.schemas import ( EnvCreate, @@ -98,7 +99,7 @@ def add_environment( @router.post("/setup-default", response_model=EnvResponse, status_code=status.HTTP_201_CREATED) def setup_default_environment( db: Session = Depends(get_db), - current_user: User = Depends(require_role(Role.EDITOR)), + current_user: User = Depends(require_package_op("install")), ): """Create a default environment with essential Robot Framework libraries.""" # Check if roboscope-default already exists @@ -264,7 +265,7 @@ def get_dockerfile( def docker_build( env_id: int, db: Session = Depends(get_db), - _current_user: User = Depends(require_role(Role.EDITOR)), + _current_user: User = Depends(require_package_op("docker_build")), ): """Build a Docker image for this environment.""" env = get_environment(db, env_id) @@ -530,7 +531,7 @@ def install_package( env_id: int, data: PackageCreate, db: Session = Depends(get_db), - _current_user: User = Depends(require_role(Role.EDITOR)), + _current_user: User = Depends(require_package_op("install")), ): """Install a package in an environment.""" env = get_environment(db, env_id) @@ -573,7 +574,7 @@ def upgrade_package( env_id: int, package_name: str, db: Session = Depends(get_db), - _current_user: User = Depends(require_role(Role.EDITOR)), + _current_user: User = Depends(require_package_op("upgrade")), ): """Upgrade a package to its latest version.""" env = get_environment(db, env_id) @@ -613,7 +614,7 @@ def retry_package_install( env_id: int, package_name: str, db: Session = Depends(get_db), - _current_user: User = Depends(require_role(Role.EDITOR)), + _current_user: User = Depends(require_package_op("install")), ): """Retry a failed package installation.""" env = get_environment(db, env_id) @@ -651,7 +652,7 @@ def retry_package_install( def run_rfbrowser_init( env_id: int, db: Session = Depends(get_db), - _current_user: User = Depends(require_role(Role.EDITOR)), + _current_user: User = Depends(require_package_op("rfbrowser_init")), ): """Manually trigger 'rfbrowser init' for an environment.""" env = get_environment(db, env_id) @@ -695,7 +696,7 @@ def uninstall_package( env_id: int, package_name: str, db: Session = Depends(get_db), - _current_user: User = Depends(require_role(Role.EDITOR)), + _current_user: User = Depends(require_package_op("uninstall")), ): """Remove a package from an environment.""" env = get_environment(db, env_id) diff --git a/backend/src/explorer/router.py b/backend/src/explorer/router.py index f3c1da05..cafe4f51 100644 --- a/backend/src/explorer/router.py +++ b/backend/src/explorer/router.py @@ -120,7 +120,9 @@ def get_project_keywords( _current_user: User = Depends(get_current_user), ): """List all user-defined keywords from .robot/.resource files in the repo.""" - repo = _get_repo(db, repo_id) + repo = get_repository(db, repo_id) + if repo is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found") from src.explorer.service import parse_robot_keywords_in_repo return parse_robot_keywords_in_repo(repo.local_path) diff --git a/backend/src/governance/__init__.py b/backend/src/governance/__init__.py new file mode 100644 index 00000000..98d9d690 --- /dev/null +++ b/backend/src/governance/__init__.py @@ -0,0 +1 @@ +"""Deployment governance — feature flags + lockdown (Epic GOV).""" diff --git a/backend/src/governance/dependencies.py b/backend/src/governance/dependencies.py new file mode 100644 index 00000000..38d14493 --- /dev/null +++ b/backend/src/governance/dependencies.py @@ -0,0 +1,79 @@ +"""FastAPI dependencies enforcing deployment governance (Epic GOV). + +`require_feature` is a generic flag gate; `require_package_op` composes the +packageManagement flag gate (absolute — even ADMIN is blocked when off) with +the configurable per-op role floor (GOV-4). The flag check runs first: a +locked deployment 403s before any role consideration. + +Blocked attempts are written to the audit log here, because the audit +MIDDLEWARE deliberately skips responses with status >= 400 — a 403 raised from +a dependency would otherwise leave no trace (FR-6). +""" + +from __future__ import annotations + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from src.audit.service import log_audit +from src.auth.constants import ROLE_HIERARCHY, Role +from src.auth.dependencies import get_current_user +from src.auth.models import User +from src.database import get_db +from src.governance.flags import resolve_flag, resolve_package_op_role + + +def _audit_block(db: Session, request: Request, user: User, detail: str) -> None: + """Record a governance-blocked mutation, then commit so it survives the + 403 (the request's session is otherwise rolled back / never committed).""" + log_audit( + db, + user_id=user.id, + username=user.email, + action="blocked", + resource_type="environment_package", + detail=detail, + ip_address=request.client.host if request.client else None, + ) + db.commit() + + +def require_feature(flag: str): + """Dependency: 403 when `flag` is disabled for this deployment.""" + + def dep(db: Session = Depends(get_db)) -> None: + if not resolve_flag(db, flag).value: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"feature_disabled:{flag}", + ) + + return dep + + +def require_package_op(op: str): + """Dependency: gate a package operation behind the packageManagement flag + (absolute) then the configurable role floor (GOV-4).""" + + def dep( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), + ) -> User: + if not resolve_flag(db, "packageManagement").value: + _audit_block(db, request, current_user, f"feature_disabled:packageManagement:{op}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="feature_disabled:packageManagement", + ) + floor = resolve_package_op_role(db, op) + user_level = ROLE_HIERARCHY.get(Role(current_user.role), -1) + if user_level < ROLE_HIERARCHY.get(floor, 999): + _audit_block(db, request, current_user, f"insufficient_role:{op}:{current_user.role}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="insufficient_role", + ) + return current_user + + return dep diff --git a/backend/src/governance/flags.py b/backend/src/governance/flags.py new file mode 100644 index 00000000..6cfa21a0 --- /dev/null +++ b/backend/src/governance/flags.py @@ -0,0 +1,93 @@ +"""Feature-flag registry + resolver (Epic GOV). + +Resolves deployment-level feature toggles with precedence + + ENV var > app_settings (DB) > built-in default (ON) + +so an operator can disable a whole feature area (v1: package management) +independent of user roles. The ENV override is the hard lock for managed / +remote installs where the admin team owns the host, not the app — it wins over +any in-app admin toggle and marks the flag ``locked`` (the UI renders it +non-editable). See _bmad-output/planning-artifacts/gov-architecture.md. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from src.auth.constants import Role +from src.settings.service import get_setting_value + +# Flag registry: key -> default value. Default is ON so upgrades never silently +# remove a feature. Adding a flag here (+ a seed row in settings) is all it +# takes to govern a new area. +FEATURE_FLAGS: dict[str, bool] = { + "packageManagement": True, +} + +SETTINGS_CATEGORY = "features" +_ENV_PREFIX = "ROBOSCOPE_FEATURE_" +_TRUE = {"1", "true", "yes", "on"} +_FALSE = {"0", "false", "no", "off"} + +# Package operations gated by the configurable role floor (GOV-4). Consulted +# only when the packageManagement area is ON. +PACKAGE_OPS = ("install", "uninstall", "upgrade", "docker_build", "rfbrowser_init") +PACKAGE_OP_ROLE_DEFAULT = Role.EDITOR # matches pre-GOV behavior + + +@dataclass(frozen=True) +class ResolvedFlag: + """A flag's resolved value plus whether it was locked by an ENV override.""" + + value: bool + locked: bool + + +def settings_key(flag: str) -> str: + """The `app_settings.key` for a feature flag (category `features`).""" + return f"{SETTINGS_CATEGORY}.{flag}" + + +def env_key(flag: str) -> str: + """ROBOSCOPE_FEATURE_, e.g. packageManagement -> ...PACKAGE_MANAGEMENT.""" + snake = "".join(f"_{c}" if c.isupper() else c for c in flag).upper().strip("_") + return _ENV_PREFIX + snake + + +def _parse_bool(raw: str | None) -> bool | None: + if raw is None: + return None + v = raw.strip().lower() + if v in _TRUE: + return True + if v in _FALSE: + return False + return None + + +def resolve_flag(db: Session, key: str) -> ResolvedFlag: + """Resolve one flag: ENV (locked) > DB > registry default (ON).""" + default = FEATURE_FLAGS.get(key, True) + env = _parse_bool(os.environ.get(env_key(key))) + if env is not None: + return ResolvedFlag(value=env, locked=True) + db_value = _parse_bool(get_setting_value(db, settings_key(key))) + return ResolvedFlag(value=db_value if db_value is not None else default, locked=False) + + +def resolve_all(db: Session) -> dict[str, ResolvedFlag]: + """Resolve every registered flag.""" + return {key: resolve_flag(db, key) for key in FEATURE_FLAGS} + + +def resolve_package_op_role(db: Session, op: str) -> Role: + """Configurable minimum role for a package operation (default EDITOR).""" + raw = get_setting_value(db, f"{SETTINGS_CATEGORY}.packageManagement.role.{op}") + try: + return Role(raw) if raw else PACKAGE_OP_ROLE_DEFAULT + except ValueError: + return PACKAGE_OP_ROLE_DEFAULT diff --git a/backend/src/governance/router.py b/backend/src/governance/router.py new file mode 100644 index 00000000..cc6c1ac0 --- /dev/null +++ b/backend/src/governance/router.py @@ -0,0 +1,29 @@ +"""Governance read endpoint — exposes resolved feature flags to the frontend.""" + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from src.auth.dependencies import get_current_user +from src.auth.models import User +from src.database import get_db +from src.governance.flags import resolve_all +from src.governance.schemas import FeaturesResponse + +router = APIRouter() + + +@router.get("/features", response_model=FeaturesResponse) +def get_features( + db: Session = Depends(get_db), + _current_user: User = Depends(get_current_user), +) -> FeaturesResponse: + """Return the resolved feature-flag set so the UI can gate affordances. + + Read-only and available to any authenticated user — it only tells the + frontend what to show. Enforcement lives on the individual endpoints. + """ + resolved = resolve_all(db) + return FeaturesResponse( + flags={k: r.value for k, r in resolved.items()}, + locked={k: r.locked for k, r in resolved.items()}, + ) diff --git a/backend/src/governance/schemas.py b/backend/src/governance/schemas.py new file mode 100644 index 00000000..bde0ae91 --- /dev/null +++ b/backend/src/governance/schemas.py @@ -0,0 +1,15 @@ +"""Schemas for the governance read endpoint.""" + +from pydantic import BaseModel + + +class FeaturesResponse(BaseModel): + """Resolved feature flags for the frontend. + + `flags[key]` is the effective on/off value; `locked[key]` is True when the + value was set via an ENV override (the UI renders it as non-editable — + "managed by your administrator"). + """ + + flags: dict[str, bool] + locked: dict[str, bool] diff --git a/backend/src/settings/service.py b/backend/src/settings/service.py index 71e36fe4..ee6dd57f 100644 --- a/backend/src/settings/service.py +++ b/backend/src/settings/service.py @@ -28,6 +28,17 @@ {"key": "deprovision_retention_days", "value": "90", "value_type": "int", "category": "auth", "description": "Days before deprovisioned-user cleanup."}, {"key": "admin_contact_email", "value": "admin@roboscope.local", "value_type": "string", "category": "auth", "description": "Displayed on SSO outage screen as admin contact."}, {"key": "hide_local_login_form", "value": "false", "value_type": "bool", "category": "auth", "description": "Hide the email/password login form when SSO is the only permitted login path."}, + # Epic GOV — deployment governance feature flags (category "features"). + # Default ON = today's behavior. An ENV override (ROBOSCOPE_FEATURE_) + # wins over this DB value and locks the toggle in the UI. + {"key": "features.packageManagement", "value": "true", "value_type": "bool", "category": "features", "description": "Allow users to install/uninstall/upgrade packages, build Docker images, and run rfbrowser init. Turn off on managed/remote installs where environments are administered centrally."}, + # GOV-4 — minimum role per package operation (only consulted when + # packageManagement is ON). Default "editor" preserves pre-GOV behavior. + {"key": "features.packageManagement.role.install", "value": "editor", "value_type": "string", "category": "features", "description": "Minimum role to install packages / set up the default environment."}, + {"key": "features.packageManagement.role.uninstall", "value": "editor", "value_type": "string", "category": "features", "description": "Minimum role to uninstall packages."}, + {"key": "features.packageManagement.role.upgrade", "value": "editor", "value_type": "string", "category": "features", "description": "Minimum role to upgrade packages."}, + {"key": "features.packageManagement.role.docker_build", "value": "editor", "value_type": "string", "category": "features", "description": "Minimum role to build Docker images."}, + {"key": "features.packageManagement.role.rfbrowser_init", "value": "editor", "value_type": "string", "category": "features", "description": "Minimum role to run rfbrowser init."}, ] diff --git a/backend/tests/ai/test_aix.py b/backend/tests/ai/test_aix.py new file mode 100644 index 00000000..c3b464f8 --- /dev/null +++ b/backend/tests/ai/test_aix.py @@ -0,0 +1,35 @@ +"""Epic AIX — verbosity directive + LiteLLM base-URL guard.""" + +from types import SimpleNamespace + +import pytest + +from src.ai.llm_client import _call_openai_compatible +from src.ai.prompts import verbosity_directive + + +class TestVerbosityDirective: + def test_concise(self): + d = verbosity_directive("concise") + assert "concise" in d.lower() + assert d.strip() != "" + + def test_detailed(self): + assert "thorough" in verbosity_directive("detailed").lower() + + @pytest.mark.parametrize("v", [None, "standard", "weird"]) + def test_standard_or_unknown_is_empty(self, v): + assert verbosity_directive(v) == "" + + +class TestLiteLLMGuard: + def test_litellm_without_base_url_raises(self): + provider = SimpleNamespace( + provider_type="litellm", + api_base_url=None, + model_name="gpt-4o", + temperature=0.3, + max_tokens=1024, + ) + with pytest.raises(ValueError, match="Base URL"): + _call_openai_compatible(provider, None, "sys", "user") diff --git a/backend/tests/explorer/test_router.py b/backend/tests/explorer/test_router.py index 63d61942..88cf2501 100644 --- a/backend/tests/explorer/test_router.py +++ b/backend/tests/explorer/test_router.py @@ -503,3 +503,27 @@ def test_library_check_response_counts( assert data["missing_count"] == sum(1 for l in libs if l["status"] == "missing") assert data["installed_count"] == sum(1 for l in libs if l["status"] == "installed") assert data["builtin_count"] == sum(1 for l in libs if l["status"] == "builtin") + + +class TestProjectKeywords: + """GET /explorer/{repo_id}/keywords — RES-1 regression (endpoint 500'd on a + NameError for _get_repo before the fix, so resource keywords never loaded).""" + + def test_lists_resource_keywords(self, client, admin_user, repo_with_files): + resp = client.get( + f"/api/v1/explorer/{repo_with_files.id}/keywords", + headers=auth_header(admin_user), + ) + assert resp.status_code == 200 + kws = resp.json() + names = {k["name"] for k in kws} + assert "My Keyword" in names + entry = next(k for k in kws if k["name"] == "My Keyword") + assert entry["file_path"] == "resources/common.resource" + + def test_unknown_repo_404(self, client, admin_user): + resp = client.get( + "/api/v1/explorer/999999/keywords", + headers=auth_header(admin_user), + ) + assert resp.status_code == 404 diff --git a/backend/tests/governance/__init__.py b/backend/tests/governance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/governance/test_flags.py b/backend/tests/governance/test_flags.py new file mode 100644 index 00000000..97b4053f --- /dev/null +++ b/backend/tests/governance/test_flags.py @@ -0,0 +1,114 @@ +"""Unit tests for the GOV feature-flag resolver — precedence ENV > DB > default.""" + +import pytest + +from src.auth.constants import Role +from src.governance.flags import ( + env_key, + resolve_flag, + resolve_package_op_role, + settings_key, +) +from src.settings.models import AppSetting + + +def _set_db_flag(db, flag, value: str): + db.add( + AppSetting( + key=settings_key(flag), + value=value, + value_type="bool", + category="features", + ) + ) + db.flush() + + +class TestEnvKey: + def test_camel_to_upper_snake(self): + assert env_key("packageManagement") == "ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT" + + +class TestPrecedence: + def test_default_is_on_when_unset(self, db_session, monkeypatch): + monkeypatch.delenv("ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT", raising=False) + r = resolve_flag(db_session, "packageManagement") + assert r.value is True + assert r.locked is False + + def test_db_false_turns_off(self, db_session, monkeypatch): + monkeypatch.delenv("ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT", raising=False) + _set_db_flag(db_session, "packageManagement", "false") + r = resolve_flag(db_session, "packageManagement") + assert r.value is False + assert r.locked is False + + def test_env_true_beats_db_false_and_locks(self, db_session, monkeypatch): + _set_db_flag(db_session, "packageManagement", "false") + monkeypatch.setenv("ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT", "true") + r = resolve_flag(db_session, "packageManagement") + assert r.value is True + assert r.locked is True + + def test_env_false_beats_db_true_and_locks(self, db_session, monkeypatch): + _set_db_flag(db_session, "packageManagement", "true") + monkeypatch.setenv("ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT", "0") + r = resolve_flag(db_session, "packageManagement") + assert r.value is False + assert r.locked is True + + @pytest.mark.parametrize("raw,expected", [("yes", True), ("on", True), ("no", False), ("off", False)]) + def test_bool_parsing_variants(self, db_session, monkeypatch, raw, expected): + monkeypatch.setenv("ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT", raw) + assert resolve_flag(db_session, "packageManagement").value is expected + + def test_garbage_env_falls_through_to_default(self, db_session, monkeypatch): + monkeypatch.setenv("ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT", "maybe") + # unparseable env is ignored → default ON, not locked + r = resolve_flag(db_session, "packageManagement") + assert r.value is True + assert r.locked is False + + @pytest.mark.parametrize("raw,expected", [(" TRUE ", True), ("\toff\n", False), ("On", True)]) + def test_whitespace_and_case_insensitive(self, db_session, monkeypatch, raw, expected): + monkeypatch.setenv("ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT", raw) + assert resolve_flag(db_session, "packageManagement").value is expected + + def test_empty_db_value_falls_through_to_default(self, db_session, monkeypatch): + monkeypatch.delenv("ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT", raising=False) + _set_db_flag(db_session, "packageManagement", "") # empty string == "unset" + r = resolve_flag(db_session, "packageManagement") + assert r.value is True + assert r.locked is False + + +class TestRoleFloor: + def test_default_is_editor(self, db_session): + assert resolve_package_op_role(db_session, "install") == Role.EDITOR + + def test_db_override(self, db_session): + db_session.add( + AppSetting( + key="features.packageManagement.role.install", + value="admin", + value_type="string", + category="features", + ) + ) + db_session.flush() + assert resolve_package_op_role(db_session, "install") == Role.ADMIN + + def test_unknown_op_falls_back_to_default(self, db_session): + assert resolve_package_op_role(db_session, "nonsense_op") == Role.EDITOR + + def test_invalid_role_falls_back_to_default(self, db_session): + db_session.add( + AppSetting( + key="features.packageManagement.role.install", + value="superuser", + value_type="string", + category="features", + ) + ) + db_session.flush() + assert resolve_package_op_role(db_session, "install") == Role.EDITOR diff --git a/backend/tests/governance/test_package_lockdown.py b/backend/tests/governance/test_package_lockdown.py new file mode 100644 index 00000000..01e430be --- /dev/null +++ b/backend/tests/governance/test_package_lockdown.py @@ -0,0 +1,131 @@ +"""Endpoint tests for GOV-2/GOV-4 — package-management lockdown + role floor.""" + +import pytest +from sqlalchemy import select + +from src.audit.models import AuditLog +from src.environments.models import Environment +from src.settings.models import AppSetting +from tests.conftest import auth_header + +ENV_FLAG = "ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT" + + +@pytest.fixture +def env(db_session, admin_user): + e = Environment(name="gov-env", python_version="3.12", created_by=admin_user.id) + db_session.add(e) + db_session.flush() + db_session.refresh(e) + return e + + +def _mutating_calls(client, env_id, headers): + """(label, response) for every package-mutating endpoint.""" + return [ + ("install", client.post(f"/api/v1/environments/{env_id}/packages", json={"name": "robotframework"}, headers=headers)), + ("upgrade", client.post(f"/api/v1/environments/{env_id}/packages/robotframework/upgrade", headers=headers)), + ("retry", client.post(f"/api/v1/environments/{env_id}/packages/robotframework/retry", headers=headers)), + ("uninstall", client.delete(f"/api/v1/environments/{env_id}/packages/robotframework", headers=headers)), + ("docker_build", client.post(f"/api/v1/environments/{env_id}/docker-build", headers=headers)), + ("rfbrowser_init", client.post(f"/api/v1/environments/{env_id}/rfbrowser-init", headers=headers)), + ] + + +class TestLockdownOff: + def test_all_mutating_endpoints_403_when_flag_off_even_for_admin( + self, client, env, admin_user, monkeypatch + ): + monkeypatch.setenv(ENV_FLAG, "false") + for label, resp in _mutating_calls(client, env.id, auth_header(admin_user)): + assert resp.status_code == 403, f"{label} should be 403 when locked, got {resp.status_code}" + assert "feature_disabled:packageManagement" in resp.json()["detail"], label + + def test_read_endpoint_still_works_when_locked(self, client, env, admin_user, monkeypatch): + monkeypatch.setenv(ENV_FLAG, "false") + resp = client.get(f"/api/v1/environments/{env.id}/packages", headers=auth_header(admin_user)) + assert resp.status_code == 200 + + def test_setup_default_is_locked(self, client, admin_user, monkeypatch): + # Regression: setup-default provisions a venv + installs packages, so it + # MUST be gated too (was bypassing the lockdown). + monkeypatch.setenv(ENV_FLAG, "false") + resp = client.post("/api/v1/environments/setup-default", headers=auth_header(admin_user)) + assert resp.status_code == 403 + assert "feature_disabled:packageManagement" in resp.json()["detail"] + + def test_lockdown_via_db_setting_no_env(self, client, env, admin_user, db_session, monkeypatch): + # The path the admin UI actually uses: a DB row (locked=false), no ENV var. + monkeypatch.delenv(ENV_FLAG, raising=False) + db_session.add( + AppSetting( + key="features.packageManagement", + value="false", + value_type="bool", + category="features", + ) + ) + db_session.flush() + resp = client.post( + f"/api/v1/environments/{env.id}/packages", + json={"name": "robotframework"}, + headers=auth_header(admin_user), + ) + assert resp.status_code == 403 + + def test_blocked_attempt_is_audited(self, client, env, admin_user, db_session, monkeypatch): + monkeypatch.setenv(ENV_FLAG, "false") + client.post( + f"/api/v1/environments/{env.id}/packages", + json={"name": "robotframework"}, + headers=auth_header(admin_user), + ) + rows = list( + db_session.execute( + select(AuditLog).where(AuditLog.action == "blocked") + ).scalars().all() + ) + assert any("feature_disabled:packageManagement" in (r.detail or "") for r in rows) + + +class TestRoleFloor: + def test_viewer_blocked_by_role_floor_when_enabled(self, client, env, viewer_user, monkeypatch): + monkeypatch.delenv(ENV_FLAG, raising=False) # flag ON (default) + resp = client.post( + f"/api/v1/environments/{env.id}/packages", + json={"name": "robotframework"}, + headers=auth_header(viewer_user), + ) + assert resp.status_code == 403 + assert resp.json()["detail"] == "insufficient_role" + + def test_runner_blocked_at_default_editor_floor(self, client, env, runner_user, monkeypatch): + # Adjacent boundary: RUNNER (1) < EDITOR (2) default floor → blocked. + monkeypatch.delenv(ENV_FLAG, raising=False) + resp = client.post( + f"/api/v1/environments/{env.id}/packages", + json={"name": "robotframework"}, + headers=auth_header(runner_user), + ) + assert resp.status_code == 403 + assert resp.json()["detail"] == "insufficient_role" + + +class TestFeaturesEndpoint: + def test_features_reflects_default_on(self, client, admin_user, monkeypatch): + monkeypatch.delenv(ENV_FLAG, raising=False) + resp = client.get("/api/v1/config/features", headers=auth_header(admin_user)) + assert resp.status_code == 200 + body = resp.json() + assert body["flags"]["packageManagement"] is True + assert body["locked"]["packageManagement"] is False + + def test_features_reflects_env_lock(self, client, admin_user, monkeypatch): + monkeypatch.setenv(ENV_FLAG, "false") + resp = client.get("/api/v1/config/features", headers=auth_header(admin_user)) + body = resp.json() + assert body["flags"]["packageManagement"] is False + assert body["locked"]["packageManagement"] is True + + def test_features_requires_auth(self, client): + assert client.get("/api/v1/config/features").status_code == 401 diff --git a/e2e/tests/flow-editor-resource-ux.spec.ts b/e2e/tests/flow-editor-resource-ux.spec.ts new file mode 100644 index 00000000..8a323a5a --- /dev/null +++ b/e2e/tests/flow-editor-resource-ux.spec.ts @@ -0,0 +1,120 @@ +/** + * UX waves D1–D6 (ux-flow-editor-resources.md) — the Flow Editor custom-resource + * experience. Real UI: seed a `.resource` file whose keyword declares a required + * argument, open a test in the Flow editor, and assert: + * D1 — the pinned "Your resources" section renders (separate from libraries). + * D5/D6 — the sort + filter controls are present in the palette header. + * D2 — inserting the resource keyword shows an import-confirmation toast. + * D3 — the inserted node opens with its required argument slot pre-filled. + */ +import { test, expect, type Page } from '@playwright/test'; +import { loginAndGoToDashboard } from '../helpers'; + +const API = 'http://localhost:8000/api/v1'; +const EMAIL = 'admin@roboscope.local'; +const PASSWORD = 'admin123'; + +// A resource keyword with a REQUIRED argument (drives the D3 pre-fill check), +// plus a second keyword so the file is more than a one-liner. +const RESOURCE = `*** Keywords *** +Open Login Page + [Arguments] \${url} + Log \${url} + +Submit Credentials + Log submitted +`; +const TEST_FILE = `*** Test Cases *** +Logs In + Log start +`; + +async function getToken(page: Page): Promise { + const res = await page.request.post(`${API}/auth/login`, { data: { email: EMAIL, password: PASSWORD } }); + return (await res.json()).access_token as string; +} + +test.describe('Flow Editor — custom-resource UX (D1–D6)', () => { + let token: string; + let repoId: number; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newPage(); + token = await getToken(ctx); + const stamp = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const res = await ctx.request.post(`${API}/repos`, { + headers: { Authorization: `Bearer ${token}` }, + data: { name: `res-ux-e2e-${stamp}`, repo_type: 'local', local_path: `/tmp/roboscope-res-ux-${stamp}` }, + }); + expect(res.status()).toBe(201); + repoId = (await res.json()).id as number; + const h = { Authorization: `Bearer ${token}` }; + await ctx.request.post(`${API}/explorer/${repoId}/file`, { headers: h, data: { path: 'resources/login.resource', content: RESOURCE } }); + await ctx.request.post(`${API}/explorer/${repoId}/file`, { headers: h, data: { path: 'tests/login_test.robot', content: TEST_FILE } }); + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newPage(); + const t = await getToken(ctx); + await ctx.request.delete(`${API}/repos/${repoId}`, { headers: { Authorization: `Bearer ${t}` } }).catch(() => {}); + await ctx.close(); + }); + + test.beforeEach(async ({ page }) => { + await loginAndGoToDashboard(page); + }); + + test('resources section, sort/filter controls, import toast, and arg pre-fill', async ({ page }) => { + await page.goto(`/explorer/${repoId}`); + await expect(page.locator('h1', { hasText: 'Explorer' })).toBeVisible({ timeout: 10_000 }); + const testsFolder = page.locator('text=/^tests$/').first(); + await expect(testsFolder).toBeVisible({ timeout: 10_000 }); + const fileRow = page.locator('text=login_test.robot').first(); + if (!(await fileRow.isVisible().catch(() => false))) await testsFolder.click(); + await expect(fileRow).toBeVisible({ timeout: 8_000 }); + await fileRow.click(); + + // Flow tab + await page.locator('button', { hasText: /^Flow$/ }).first().click(); + await expect(page.locator('.vue-flow__node[data-id$="-start"]').first()).toBeVisible({ timeout: 8_000 }); + + const palette = page.locator('.keyword-palette'); + + // D1 — the pinned "Your resources" section header is present. + await expect(palette.locator('[data-testid="palette-resources-label"]')).toBeVisible({ timeout: 8_000 }); + + // D5 / D6 — sort + filter controls live in the palette header. + await expect(palette.locator('[data-testid="palette-sort-btn"]')).toBeVisible(); + await expect(palette.locator('[data-testid="palette-filter-btn"]')).toBeVisible(); + await palette.locator('[data-testid="palette-filter-btn"]').click(); + await expect(palette.locator('[data-testid="palette-filter-menu"]')).toBeVisible(); + // Close the menu again before interacting with the palette body. + await palette.locator('[data-testid="palette-filter-btn"]').click(); + + // Open the resource file category and select the keyword. + const resHeader = palette.locator('.category-header', { hasText: 'login.resource' }).first(); + await expect(resHeader).toBeVisible({ timeout: 8_000 }); + if (!(await palette.getByText('Open Login Page', { exact: true }).first().isVisible().catch(() => false))) { + await resHeader.click(); + } + await palette.getByText('Open Login Page', { exact: true }).first().click(); + await palette.locator('.palette-add-btn').click(); + + // D2 — an import-confirmation toast appears, naming the resource file. + const toast = page.locator('.toast').filter({ hasText: /login\.resource/ }); + await expect(toast.first()).toBeVisible({ timeout: 6_000 }); + + // D3 — the inserted node's detail panel opens with the required argument + // slot already present (one `${url}` slot, not a bare "+ add argument"). + await expect(page.locator('.flow-arg-row').first()).toBeVisible({ timeout: 6_000 }); + + // The auto-import landed in the generated .robot (Code tab). + await page.locator('button', { hasText: /^Code$/ }).first().click(); + const code = page.locator('.cm-content'); + await expect(code).toBeVisible({ timeout: 8_000 }); + const text = (await code.innerText()).replace(/ /g, ' '); + expect(text).toContain('Open Login Page'); + expect(text).toMatch(/Resource\s+\.\.\/resources\/login\.resource/); + }); +}); diff --git a/e2e/tests/gov-feature-lockdown.spec.ts b/e2e/tests/gov-feature-lockdown.spec.ts new file mode 100644 index 00000000..8c307826 --- /dev/null +++ b/e2e/tests/gov-feature-lockdown.spec.ts @@ -0,0 +1,94 @@ +/** + * Epic GOV — deployment feature lockdown, exercised through the LIVE UI. + * + * Toggles the `features.packageManagement` flag via the settings API (DB + * precedence — no backend restart needed), then drives the real Environments + * page to confirm: when locked, the package-management controls are gone, a + * "managed by your administrator" notice is shown, the package list still + * renders read-only, and a direct API mutation is refused with 403. The flag + * is always restored to ON in a finally / afterAll so the shared backend is + * left in its default state. + */ +import { test, expect, type Page } from '@playwright/test'; +import { loginAndGoToDashboard } from '../helpers'; + +const API = 'http://localhost:8000/api/v1'; +const EMAIL = 'admin@roboscope.local'; +const PASSWORD = 'admin123'; +const INSTALL_BTN = /Paket installieren/; + +async function getToken(page: Page): Promise { + const res = await page.request.post(`${API}/auth/login`, { data: { email: EMAIL, password: PASSWORD } }); + return (await res.json()).access_token as string; +} + +async function setPackageMgmt(page: Page, token: string, enabled: boolean) { + const res = await page.request.patch(`${API}/settings`, { + headers: { Authorization: `Bearer ${token}` }, + data: { settings: [{ key: 'features.packageManagement', value: enabled ? 'true' : 'false' }] }, + }); + expect(res.ok()).toBeTruthy(); +} + +async function expandEnv(page: Page, name: string) { + await page.goto('/environments'); + await expect(page.locator('h1', { hasText: 'Umgebungen' })).toBeVisible({ timeout: 10_000 }); + await page.locator('.card-header', { hasText: name }).first().click(); +} + +test.describe('GOV — package-management lockdown (live UI)', () => { + let token: string; + let envId: number; + const envName = `gov-e2e-${Date.now()}`; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newPage(); + token = await getToken(ctx); + const res = await ctx.request.post(`${API}/environments`, { + headers: { Authorization: `Bearer ${token}` }, + data: { name: envName, python_version: '3.12' }, + }); + expect(res.status()).toBe(201); + envId = (await res.json()).id as number; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newPage(); + const t = await getToken(ctx); + await setPackageMgmt(ctx, t, true); // always restore default + await ctx.request.delete(`${API}/environments/${envId}`, { headers: { Authorization: `Bearer ${t}` } }); + await ctx.close(); + }); + + test.beforeEach(async ({ page }) => { + await loginAndGoToDashboard(page); + }); + + test('default (enabled): install control is visible', async ({ page }) => { + await setPackageMgmt(page, await getToken(page), true); + await expandEnv(page, envName); + await expect(page.getByRole('button', { name: INSTALL_BTN })).toBeVisible({ timeout: 8_000 }); + await expect(page.locator('.pkg-managed-notice')).toHaveCount(0); + }); + + test('locked (disabled): controls hidden, notice shown, API mutation 403', async ({ page }) => { + const t = await getToken(page); + await setPackageMgmt(page, t, false); + try { + await expandEnv(page, envName); + // Read-only notice present, mutating controls gone. + await expect(page.locator('.pkg-managed-notice')).toBeVisible({ timeout: 8_000 }); + await expect(page.getByRole('button', { name: INSTALL_BTN })).toHaveCount(0); + // Server enforcement: a direct API mutation is refused even for admin. + const resp = await page.request.post(`${API}/environments/${envId}/packages`, { + headers: { Authorization: `Bearer ${t}` }, + data: { name: 'robotframework' }, + }); + expect(resp.status()).toBe(403); + expect(JSON.stringify(await resp.json())).toContain('feature_disabled:packageManagement'); + } finally { + await setPackageMgmt(page, t, true); // restore promptly even if an assertion fails + } + }); +}); diff --git a/e2e/tests/resource-autoimport.spec.ts b/e2e/tests/resource-autoimport.spec.ts new file mode 100644 index 00000000..197eb57d --- /dev/null +++ b/e2e/tests/resource-autoimport.spec.ts @@ -0,0 +1,93 @@ +/** + * Epic RES — inserting a keyword from a repo `.resource` file auto-adds the + * matching `Resource` import, so the keyword actually resolves at runtime + * (Daniel's "make local/repository resources usable"). Real UI: seed a + * resource + a test file, open the test in the Flow editor, insert the + * resource keyword from the palette, and assert the generated `.robot` + * (Code tab) carries `Resource ../resources/common.resource` + the call. + */ +import { test, expect, type Page } from '@playwright/test'; +import { loginAndGoToDashboard } from '../helpers'; + +const API = 'http://localhost:8000/api/v1'; +const EMAIL = 'admin@roboscope.local'; +const PASSWORD = 'admin123'; + +const RESOURCE = `*** Keywords *** +My Shared Keyword + Log shared +`; +const TEST_FILE = `*** Test Cases *** +Uses It + Log hi +`; + +async function getToken(page: Page): Promise { + const res = await page.request.post(`${API}/auth/login`, { data: { email: EMAIL, password: PASSWORD } }); + return (await res.json()).access_token as string; +} + +test.describe('RES — Resource auto-import on keyword insert', () => { + let token: string; + let repoId: number; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newPage(); + token = await getToken(ctx); + const stamp = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const res = await ctx.request.post(`${API}/repos`, { + headers: { Authorization: `Bearer ${token}` }, + data: { name: `res-e2e-${stamp}`, repo_type: 'local', local_path: `/tmp/roboscope-res-${stamp}` }, + }); + expect(res.status()).toBe(201); + repoId = (await res.json()).id as number; + const h = { Authorization: `Bearer ${token}` }; + await ctx.request.post(`${API}/explorer/${repoId}/file`, { headers: h, data: { path: 'resources/common.resource', content: RESOURCE } }); + await ctx.request.post(`${API}/explorer/${repoId}/file`, { headers: h, data: { path: 'tests/uses_resource.robot', content: TEST_FILE } }); + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newPage(); + const t = await getToken(ctx); + await ctx.request.delete(`${API}/repos/${repoId}`, { headers: { Authorization: `Bearer ${t}` } }).catch(() => {}); + await ctx.close(); + }); + + test.beforeEach(async ({ page }) => { + await loginAndGoToDashboard(page); + }); + + test('inserting a .resource keyword adds the Resource import', async ({ page }) => { + await page.goto(`/explorer/${repoId}`); + await expect(page.locator('h1', { hasText: 'Explorer' })).toBeVisible({ timeout: 10_000 }); + const testsFolder = page.locator('text=/^tests$/').first(); + await expect(testsFolder).toBeVisible({ timeout: 10_000 }); + const fileRow = page.locator('text=uses_resource.robot').first(); + if (!(await fileRow.isVisible().catch(() => false))) await testsFolder.click(); + await expect(fileRow).toBeVisible({ timeout: 8_000 }); + await fileRow.click(); + + // Flow tab + await page.locator('button', { hasText: /^Flow$/ }).first().click(); + await expect(page.locator('.vue-flow__node[data-id$="-start"]').first()).toBeVisible({ timeout: 8_000 }); + + // Open the "Project: common.resource" palette category, select the keyword, add it. + const palette = page.locator('.keyword-palette'); + const projHeader = palette.locator('.category-header', { hasText: 'common.resource' }).first(); + await expect(projHeader).toBeVisible({ timeout: 8_000 }); + if (!(await palette.getByText('My Shared Keyword', { exact: true }).first().isVisible().catch(() => false))) { + await projHeader.click(); + } + await palette.getByText('My Shared Keyword', { exact: true }).first().click(); + await palette.locator('.palette-add-btn').click(); + + // Code tab — the import + call must be present. + await page.locator('button', { hasText: /^Code$/ }).first().click(); + const code = page.locator('.cm-content'); + await expect(code).toBeVisible({ timeout: 8_000 }); + const text = (await code.innerText()).replace(/ /g, ' '); + expect(text).toContain('My Shared Keyword'); + expect(text).toMatch(/Resource\s+\.\.\/resources\/common\.resource/); + }); +}); diff --git a/e2e/tests/run-tags.spec.ts b/e2e/tests/run-tags.spec.ts new file mode 100644 index 00000000..66c0aa2c --- /dev/null +++ b/e2e/tests/run-tags.spec.ts @@ -0,0 +1,73 @@ +/** + * Epic EXEC (EXEC-1/EXEC-3) — the run dialog now exposes Include/Exclude tags, + * which the backend runner already turns into `robot --include/--exclude`. + * Real UI: open the New Run dialog, set an include tag, start, and assert the + * create-run request carries `tags_include` (proving the dialog → backend wire; + * the runner application is already covered by backend tests). + */ +import { test, expect, type Page } from '@playwright/test'; +import { loginAndGoToDashboard } from '../helpers'; + +const API = 'http://localhost:8000/api/v1'; +const EMAIL = 'admin@roboscope.local'; +const PASSWORD = 'admin123'; + +async function getToken(page: Page): Promise { + const res = await page.request.post(`${API}/auth/login`, { data: { email: EMAIL, password: PASSWORD } }); + return (await res.json()).access_token as string; +} + +test.describe('EXEC — run dialog passes tags', () => { + let token: string; + let repoId: number; + let envId: number; + const repoName = `exec-e2e-${Date.now()}`; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newPage(); + token = await getToken(ctx); + const h = { Authorization: `Bearer ${token}` }; + const r = await ctx.request.post(`${API}/repos`, { headers: h, data: { name: repoName, repo_type: 'local', local_path: `/tmp/${repoName}` } }); + repoId = (await r.json()).id as number; + await ctx.request.post(`${API}/explorer/${repoId}/file`, { headers: h, data: { path: 'tests/x.robot', content: '*** Test Cases ***\nDemo\n [Tags] smoke\n Log hi\n' } }); + // An environment must exist so the dialog doesn't divert to the "set up env" prompt. + const e = await ctx.request.post(`${API}/environments`, { headers: h, data: { name: `${repoName}-env`, python_version: '3.12' } }); + envId = (await e.json()).id as number; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newPage(); + const t = await getToken(ctx); + const h = { Authorization: `Bearer ${t}` }; + await ctx.request.delete(`${API}/repos/${repoId}`, { headers: h }).catch(() => {}); + await ctx.request.delete(`${API}/environments/${envId}`, { headers: h }).catch(() => {}); + await ctx.close(); + }); + + test.beforeEach(async ({ page }) => { + await loginAndGoToDashboard(page); + }); + + test('Include tags are sent on the create-run request', async ({ page }) => { + await page.goto('/runs'); + await expect(page.getByRole('button', { name: /Neuer Run|New Run/ })).toBeVisible({ timeout: 10_000 }); + + await page.getByRole('button', { name: /Neuer Run|New Run/ }).first().click(); + + // Repo select carries the "please select" placeholder option. + const repoSelect = page.locator('select', { has: page.locator('option', { hasText: repoName }) }).first(); + await expect(repoSelect).toBeVisible({ timeout: 8_000 }); + await repoSelect.selectOption({ label: repoName }); + + await page.getByPlaceholder('smoke, regression').first().fill('smoke'); + + const reqPromise = page.waitForRequest( + (req) => req.url().endsWith('/runs') && req.method() === 'POST', + { timeout: 8_000 }, + ); + await page.getByRole('button', { name: /^(Start|Starten)$/ }).first().click(); + const req = await reqPromise; + expect(req.postDataJSON().tags_include).toBe('smoke'); + }); +}); diff --git a/frontend/src/api/governance.api.ts b/frontend/src/api/governance.api.ts new file mode 100644 index 00000000..c3bb025f --- /dev/null +++ b/frontend/src/api/governance.api.ts @@ -0,0 +1,14 @@ +import apiClient from './client' + +export interface FeaturesResponse { + /** Effective on/off value per feature flag. */ + flags: Record + /** True when the flag was set via an ENV override (UI shows it non-editable). */ + locked: Record +} + +/** Fetch the resolved deployment feature flags. */ +export async function getFeatures(): Promise { + const response = await apiClient.get('/config/features') + return response.data +} diff --git a/frontend/src/components/ai/ProviderConfig.vue b/frontend/src/components/ai/ProviderConfig.vue index 3ed27c2e..677c977a 100644 --- a/frontend/src/components/ai/ProviderConfig.vue +++ b/frontend/src/components/ai/ProviderConfig.vue @@ -33,6 +33,7 @@ const providerTypes = [ { value: 'anthropic', label: 'Anthropic (Claude)' }, { value: 'openrouter', label: 'OpenRouter' }, { value: 'ollama', label: 'Ollama (Local)' }, + { value: 'litellm', label: 'LiteLLM (Gateway)' }, ] const providerModels: Record = { @@ -60,6 +61,7 @@ const providerModels: Record = { 'codellama', 'deepseek-coder-v2', 'dolphin-mistral', 'dolphin-mistral:latest', ], + litellm: [], // freeform — the gateway decides which model names are valid } const defaultModels: Record = { @@ -67,6 +69,7 @@ const defaultModels: Record = { anthropic: 'claude-sonnet-4-6', openrouter: 'anthropic/claude-sonnet-4-6', ollama: 'dolphin-mistral:latest', + litellm: '', } onMounted(async () => { @@ -109,6 +112,7 @@ function openEditDialog(provider: AiProvider) { } const isOllama = computed(() => form.value.provider_type === 'ollama') +const isLiteLLM = computed(() => form.value.provider_type === 'litellm') const baseUrlPlaceholder = computed(() => isOllama.value ? 'http://localhost:11434' : 'https://...' @@ -220,6 +224,7 @@ async function handleSetDefault(provider: AiProvider) {
{{ error }}

{{ t('ai.ollamaHint') }}

+

{{ t('ai.litellmHint') }}

@@ -271,6 +276,7 @@ async function handleSetDefault(provider: AiProvider) {
{{ error }}

{{ t('ai.ollamaHint') }}

+

{{ t('ai.litellmHint') }}

diff --git a/frontend/src/components/editor/FlowEditor.vue b/frontend/src/components/editor/FlowEditor.vue index 2fa3f1f3..2514f5b5 100644 --- a/frontend/src/components/editor/FlowEditor.vue +++ b/frontend/src/components/editor/FlowEditor.vue @@ -26,6 +26,7 @@ import { extractErrorDetail } from '@/utils/errors' import { collectEnvVarRefs } from '@/utils/robotEnvVars' import { type RecordedFlow, type SelectorCandidate, type SelectorStrategy } from '@/types/recorder.types' import { useKeywordSignatures } from '@/composables/useKeywordSignatures' +import { useToast } from '@/composables/useToast' import { getArgLabel, friendlyType, @@ -112,6 +113,7 @@ const emit = defineEmits<{ }>() const { t } = useI18n() +const toast = useToast() const nodes = ref([]) const edges = ref([]) @@ -181,6 +183,17 @@ const libraryEntries = computed(() => { return out }) +// UX D1 — the imports panel splits into separate "Resources" and +// "Libraries" lists so a `.resource` import never sits under "Libraries" +// (RF distinguishes the two). Both derive from the same libraryEntries so +// removeLibrary(idx) still targets the right form.settings row. +const resourceImportEntries = computed(() => + libraryEntries.value.filter(e => e.kind === 'resource'), +) +const libraryImportEntries = computed(() => + libraryEntries.value.filter(e => e.kind === 'library'), +) + /** Lower-cased set of imported library names. Always includes * `'builtin'` because RF auto-imports BuiltIn. Drives the * KeywordPalette dimming + auto-import logic. @@ -230,9 +243,9 @@ const _RF_BUNDLED = new Set([ * are treated as Resource imports instead. Emits * `libraries-changed` so the parent can refresh the keyword * cache. */ -function addLibrary(rawName: string): void { +function addLibrary(rawName: string): 'library' | 'resource' | false { const name = rawName.trim() - if (!name) return + if (!name) return false const isResource = name.toLowerCase().endsWith('.resource') || name.includes('/') const key = isResource ? 'Resource' : 'Library' // Dedupe — RF accepts duplicate Library imports but they're noise. @@ -240,7 +253,7 @@ function addLibrary(rawName: string): void { s => s.key.toLowerCase() === key.toLowerCase() && s.value.trim().toLowerCase() === name.toLowerCase(), ) - if (existing) return + if (existing) return false props.form.settings.push({ key, value: name, args: [] }) libraryInputValue.value = '' // Only third-party Library imports trigger the env-introspection @@ -248,6 +261,25 @@ function addLibrary(rawName: string): void { // libs (Collections, XML, …) don't need pip install. const skipInstallCheck = isResource || _RF_BUNDLED.has(name.toLowerCase()) emit('libraries-changed', skipInstallCheck ? undefined : name) + return isResource ? 'resource' : 'library' +} + +/** + * UX D2 (ux-flow-editor-resources.md) — surface the otherwise-silent + * auto-import. Shows a localized confirmation toast ONLY when an import + * was actually added (caller passes `addLibrary`'s return), naming the + * file/library. No Undo (decided 2026-06-18 — removal lives in the + * Resources/Libraries panel). Never fires for BuiltIn / same-file / + * already-imported inserts because those never reach `addLibrary` with + * a truthy result. + */ +function notifyImportAdded(kind: 'library' | 'resource', name: string): void { + toast.success( + t(kind === 'resource' + ? 'flowEditor.importAdded.resourceTitle' + : 'flowEditor.importAdded.libraryTitle'), + t('flowEditor.importAdded.message', { name }), + ) } function confirmAddLibrary(): void { @@ -734,7 +766,34 @@ function onPanelResizeStart(ev: PointerEvent) { // Story EDITOR-2 — keyword signature map (lowercase keyword name → raw // libdoc args). Reactive: rebuilds graph automatically when the // explorer-store cache resolves after a repo open. -const { argsByName, getKeywordInfo } = useKeywordSignatures() +const { argsByName, getKeywordInfo, getParsedArgs } = useKeywordSignatures() + +/** + * UX D3 (ux-flow-editor-resources.md) — pre-seed a freshly-inserted + * keyword node's `args` with one EMPTY slot per REQUIRED positional + * argument from its signature, so the detail panel opens with the + * parameters the user must fill already labelled (e.g. a resource + * keyword `Open Login Page [Arguments] ${url}` shows a "url" + * slot instead of a bare "+ add argument"). Mirrors the "add next + * positional" picker which also pushes `''`. + * + * Only REQUIRED positionals are seeded: optionals/varargs/kwargs stay + * behind the "+ add argument" picker (seeding them would write + * redundant `name=default` cells). Required args always precede + * optionals in an RF signature, so we stop at the first non-required + * descriptor. No-op when the keyword has no known signature (the slot + * count is then driven purely by what the user adds). + */ +function prefillRequiredArgs(step: RobotStep): void { + if (step.type !== 'keyword' && step.type !== 'assignment') return + if (step.args.length > 0) return // never clobber caller-provided args + const specs = getParsedArgs(step.keyword) + if (!specs) return + for (const s of specs) { + if (s.kind === 'positional') step.args.push('') + else break + } +} // Library usage histogram for the currently-open file. Walks every // keyword / assignment step in test cases AND keyword definitions @@ -758,6 +817,18 @@ const libraryUsageCounts = computed>(() => { return counts }) +// UX D6 — heuristic inputs for the palette's adaptive "what's shown" +// default: how many Library/Resource imports the open file has, and its +// total step count. A "sophisticated" file (≥1 import OR ≥5 steps) with an +// environment defaults the palette to imported-only. +const fileImportCount = computed(() => libraryEntries.value.length) +const fileStepCount = computed(() => { + let n = 0 + for (const tc of props.form.testCases) n += tc.steps.length + for (const kw of props.form.keywords) n += kw.steps.length + return n +}) + function buildGraph() { const sc = props.sidecar ?? null const sig = argsByName.value @@ -1574,11 +1645,16 @@ function addNodeFromPalette(step: RobotStep, library?: string) { // and emits `libraries-changed` (which refreshes the palette // signatures cache). if (library) { - const lower = library.toLowerCase() - const already = importedLibraryNames.value.has(lower) - if (!already) addLibrary(library) + // addLibrary dedupes internally (incl. Resource imports, which + // importedLibraryNames doesn't track) and returns the kind it added + // or false — drive the D2 import toast off that truthy result. + const added = addLibrary(library) + if (added) notifyImportAdded(added, library) } + // UX D3 — open the node with its required arguments already slotted. + prefillRequiredArgs(step) + const list = activeSection.value === 'testcases' ? props.form.testCases[activeItemIndex.value]?.steps : props.form.keywords[activeItemIndex.value]?.steps @@ -1789,10 +1865,14 @@ function onCanvasDrop(event: DragEvent) { // from a not-yet-imported library, prepend the Library row before // inserting the step. const sourceLib = event.dataTransfer?.getData('application/rf-library') || '' - if (sourceLib && !importedLibraryNames.value.has(sourceLib.toLowerCase())) { - addLibrary(sourceLib) + if (sourceLib) { + const added = addLibrary(sourceLib) + if (added) notifyImportAdded(added, sourceLib) } + // UX D3 — pre-seed required argument slots (same as the palette path). + prefillRequiredArgs(step) + const flowY = eventToFlowY(event) const idx = findInsertIndex(flowY) insertStepAt(step, idx) @@ -2140,26 +2220,50 @@ function onDebugOverlayClose(): void { RobotEditor's settings-watcher refreshes the keyword cache + palette automatically. -->
-
- - {{ t('flowEditor.librariesNone') }} - - - {{ entry.kind === 'resource' ? 'R' : 'L' }} - {{ entry.value }} - - + + {{ t('flowEditor.librariesNone') }} + + +
+
{{ t('flowEditor.resourcePanelLabel') }} ({{ resourceImportEntries.length }})
+
+ + R + {{ entry.value }} + + +
+
+ +
+
{{ t('flowEditor.librariesPanelLabel') }} ({{ libraryImportEntries.length }})
+
+ + L + {{ entry.value }} + + +
@@ -3155,6 +3261,18 @@ function onDebugOverlayClose(): void { gap: 6px; margin-bottom: 8px; } +/* D1 — labelled Resources / Libraries groups in the imports panel */ +.flow-libraries__group { + margin-bottom: 6px; +} +.flow-libraries__group-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-muted, #5A6380); + margin-bottom: 4px; +} .flow-libraries__empty { color: var(--color-text-muted, #5A6380); font-size: 12px; diff --git a/frontend/src/components/editor/flow/KeywordPalette.vue b/frontend/src/components/editor/flow/KeywordPalette.vue index 497b1774..6db34df7 100644 --- a/frontend/src/components/editor/flow/KeywordPalette.vue +++ b/frontend/src/components/editor/flow/KeywordPalette.vue @@ -4,81 +4,82 @@ import { useI18n } from 'vue-i18n' import { useExplorerStore } from '@/stores/explorer.store' import { searchKeywords, type RfKeywordResult } from '@/api/ai.api' import { getProjectKeywords, type ProjectKeyword } from '@/api/explorer.api' +import { resourceImportPath } from './resourcePath' +import { + type PaletteFilter, + type PaletteSort, + PALETTE_FILTER_LS_KEY, + PALETTE_SORT_LS_KEY, + adaptiveDefaultFilter, + parseStoredFilter, + parseStoredSort, + applyFilter, + hiddenCount, + sortLibraries, + type CatLike, +} from './paletteView' import type { StepType, RobotStep } from './flowConverter' -// Story TYPE-2: discriminated union for the palette categories. -// Two shapes flow through `allCategories`: -// - keyword categories (BuiltIn, project libs, dynamic libs) +// Story TYPE-2 + UX D1: discriminated union for the palette categories. +// - keyword categories (resources, BuiltIn, project libs, dynamic libs) // - the control category (IF / FOR / VAR …) -// Tagging by which key is present lets TS narrow inside the -// `'keywords' in cat` / `'items' in cat` template branches without -// `as any`. +// A `kind` discriminator ('resource' | 'library' | 'control') drives the +// D1 "Your resources" sectioning + D5/D6 sort/filter, independent of the +// display name (resources now show the bare file basename, not a +// "Project: " shouty prefix). type ControlItem = { label: string; type: StepType } type KeywordCategory = { name: string keywords: string[] - /** True when this category comes from the hand-curated static - * fallback list (`Browser`, `BuiltIn`, … with ~10 keywords - * each), used when the dynamic libdoc introspection returns - * nothing — typically because the repo has no environment - * configured or the library isn't installed. The header gets a - * small "(examples)" suffix so users don't mistake the curated - * subset for the full library surface. */ + kind: 'resource' | 'library' + /** Curated static-fallback subset (no env / lib not installed). */ isExamples?: boolean - /** True when this Project: category corresponds to the file the - * user has open right now. Pinned to the top of the palette and - * visually marked so the user can scan to "their own" keywords - * faster than reading every Project: header. */ + /** The resource file the user has open right now (pinned + tinted). */ isCurrentFile?: boolean + /** Repo-relative directory of a resource file (D1 path subtitle). */ + relPath?: string + /** `.resource` (vs `.robot`) — picks the file glyph. */ + isResourceFile?: boolean + /** Owning library imported in the open file (drives D5 importedFirst). */ + imported?: boolean } -type ControlCategory = { name: string; items: ControlItem[] } +type ControlCategory = { name: string; items: ControlItem[]; kind: 'control' } type PaletteCategory = KeywordCategory | ControlCategory const props = defineProps<{ repoId?: number - /** Repo-relative path of the file the user is currently editing. - * Used as a re-collapse signal — every time the user switches - * files we clear `expandedCategories` so the palette opens - * in the same condensed view, regardless of how the user had - * expanded categories on the previous file. */ + /** Repo-relative path of the file the user is currently editing. */ filePath?: string - /** Lower-cased names of libraries currently imported in the file - * (`Library Browser` → `'browser'`). `BuiltIn` is always - * considered imported because RF auto-imports it. The palette - * uses this to visually dim non-imported keywords and signal - * that picking one will trigger an auto-import. */ + /** Lower-cased names of libraries currently imported in the file. */ importedLibraries?: Set - /** Library → keyword-usage tally for the currently-open file. - * Computed by the parent (FlowEditor) by walking every - * keyword / assignment step in form.testCases + form.keywords - * and resolving each step's keyword to its declaring library. - * Used to sort library categories so the libs the user is - * actively working with bubble to the top. */ + /** Library → keyword-usage tally for the currently-open file. */ usageCounts?: Map + /** UX D6 — Library + Resource import count of the open file (heuristic). */ + fileImportCount?: number + /** UX D6 — total step count across the open file (heuristic). */ + fileStepCount?: number }>() const { t } = useI18n() const explorer = useExplorerStore() const emit = defineEmits<{ - /** Add a step to the active section. `library` is the source - * Library name when the keyword came from one (so the parent can - * auto-import it if missing); `undefined` for Control items and - * Project / Resource keywords. */ + /** Add a step to the active section. `library` is the auto-import hint: + * a Library name, or an open-file-relative `.resource`/`.robot` path + * for project keywords sourced from another file; `undefined` for + * Control items, BuiltIn, and same-file project keywords. */ (e: 'add-node', step: RobotStep, library?: string): void }>() -/** Decide if a category's keywords should render as "imported" - * (full opacity) or "not imported" (dimmed + auto-import on pick). - * Always-true for BuiltIn (RF auto-imports it), Control (not a - * library), and Project / Resource categories (in-repo keywords - * are always usable from the same repo). Everything else is - * gated on `props.importedLibraries`. */ -function isCategoryImported(catName: string): boolean { - if (catName === 'BuiltIn' || catName === 'Control') return true - if (catName.startsWith('Project: ')) return true - if (!props.importedLibraries) return true // no signal → don't dim - return props.importedLibraries.has(catName.toLowerCase()) +/** Decide if a category's keywords render as "imported" (full opacity) + * or "not imported" (dimmed + auto-import on pick). True for BuiltIn, + * Control, and resources (in-repo keywords are always usable). */ +function isCategoryImported(cat: PaletteCategory): boolean { + if (cat.kind === 'control') return true + if (cat.kind === 'resource') return true + if (cat.name === 'BuiltIn') return true + if (!props.importedLibraries) return true // no signal → don't dim + return props.importedLibraries.has(cat.name.toLowerCase()) } // Dynamic keywords from environment + project .resource files @@ -129,8 +130,6 @@ async function loadDynamicKeywords() { } // Also load project-specific keywords from .robot/.resource files. - // Share them via the store so useKeywordSignatures can give them - // precedence over library/BuiltIn keywords (fixes the shadowing bug). const projKws = await getProjectKeywords(props.repoId).catch(() => []) projectKeywords.value = projKws explorer.setProjectKeywords(projKws) @@ -152,12 +151,7 @@ watch(() => explorer.keywordsLoaded, (loaded) => { }) const searchQuery = ref('') -// Track only EXPANDED categories — the palette can list 100+ -// keywords across BuiltIn / Project / dynamic libs and an expanded -// state buries the search box and "(examples)" hints. With the -// default being "collapsed unless in the set", new categories that -// pop in mid-load (e.g. libdoc finishing after the initial render) -// stay collapsed automatically — no seed pass needed. +// Track only EXPANDED categories — default collapsed. const expandedCategories = ref>(new Set()) const selectedKeyword = ref<{ name: string; type?: StepType; library?: string } | null>(null) @@ -185,19 +179,29 @@ function getKeywordArgs(name: string): string[] { return keywordArgsMap.value.get(name) || [] } -/** Map a category name to a library name suitable for the - * auto-import hint, or undefined when no auto-import should - * happen. Skips BuiltIn (RF auto-imports it implicitly) and - * Project: ... categories (those are .resource files, would need - * a Resource import — out of scope for the auto-import quick - * path; users can still add Resource manually via the library - * panel). Everything else passes through verbatim — for both - * dynamic categories (the lib name from libdoc) and static- - * fallback categories (Browser, Collections, String). */ -function libraryHintFor(catName: string): string | undefined { - if (catName === 'BuiltIn' || catName === 'Control') return undefined - if (catName.startsWith('Project: ')) return undefined - return catName +// RES — keyword name → source file path (repo-relative) for project keywords. +const projectKeywordSource = computed(() => { + const m = new Map() + for (const kw of projectKeywords.value) m.set(kw.name, kw.file_path) + return m +}) + +/** Auto-import hint passed as the `add-node` `library` arg. For a Library + * category it's the library name (BuiltIn excluded — RF auto-imports it); + * for a resource keyword sourced from another file it's the open-file- + * relative path (so FlowEditor's addLibrary creates the `Resource` + * import). Same-file resource keywords and BuiltIn return undefined. */ +function importHintFor(cat: PaletteCategory, keyword: string): string | undefined { + if (cat.kind === 'resource') { + const src = projectKeywordSource.value.get(keyword) + if (src && src !== props.filePath) { + return resourceImportPath(props.filePath || '', src) + } + return undefined + } + if (cat.kind === 'control') return undefined + if (cat.name === 'BuiltIn') return undefined + return cat.name } function selectKeyword(name: string, type?: StepType, library?: string) { @@ -216,13 +220,9 @@ function addSelectedKeyword() { selectedKeyword.value = null } -// Built-in keyword categories. The list is curated, not exhaustive -// — about 10 popular keywords per library — and is used to fill -// gaps in what the dynamic libdoc returned (BuiltIn is always -// stripped backend-side; common third-party libs may not be -// installed in the env yet). See `allCategories` for how these -// are merged with the dynamic data. -const categories: PaletteCategory[] = [ +// Built-in keyword categories (curated, ~10 each) used to fill gaps in +// the dynamic libdoc data. See `allCategories`. +const categories: { name: string; keywords: string[] }[] = [ { name: 'BuiltIn', keywords: [ @@ -309,145 +309,173 @@ const categories: PaletteCategory[] = [ 'Query', 'Row Count Is Equal To', 'Check If Exists In Database', ], }, - { - name: 'Control', - items: [ - { label: 'IF / ELSE', type: 'if' }, - { label: 'ELSE IF', type: 'else_if' }, - { label: 'ELSE', type: 'else' }, - { label: 'FOR Loop', type: 'for' }, - { label: 'WHILE Loop', type: 'while' }, - { label: 'TRY / EXCEPT', type: 'try' }, - { label: 'EXCEPT', type: 'except' }, - { label: 'FINALLY', type: 'finally' }, - { label: 'VAR', type: 'var' }, - { label: 'RETURN', type: 'return' }, - { label: 'BREAK', type: 'break' }, - { label: 'CONTINUE', type: 'continue' }, - { label: 'Comment', type: 'comment' }, - ], - }, ] -/** Common library names we ALWAYS surface in the palette via the - * static curated subset when the dynamic libdoc didn't return - * them. RF-bundled libs (Collections, String, …) are shipped - * with `robotframework` itself but still need an explicit - * `Library X` import to use; common third-party libs - * (Browser, SeleniumLibrary, …) need pip install + import. - * Either way, showing the curated subset gives the user - * discovery + the auto-import / install path on click. - * BuiltIn is the special case: always shown, never marked - * examples (RF auto-imports it). - */ +const controlCategory: ControlCategory = { + name: 'Control', + kind: 'control', + items: [ + { label: 'IF / ELSE', type: 'if' }, + { label: 'ELSE IF', type: 'else_if' }, + { label: 'ELSE', type: 'else' }, + { label: 'FOR Loop', type: 'for' }, + { label: 'WHILE Loop', type: 'while' }, + { label: 'TRY / EXCEPT', type: 'try' }, + { label: 'EXCEPT', type: 'except' }, + { label: 'FINALLY', type: 'finally' }, + { label: 'VAR', type: 'var' }, + { label: 'RETURN', type: 'return' }, + { label: 'BREAK', type: 'break' }, + { label: 'CONTINUE', type: 'continue' }, + { label: 'Comment', type: 'comment' }, + ], +} + const _ALWAYS_VISIBLE_LIBS = [ 'BuiltIn', - // RF-bundled (no pip install needed, only `Library X`): 'Collections', 'String', 'DateTime', 'OperatingSystem', 'Process', 'XML', - // Common third-party (pip install + Library import): 'Browser', 'SeleniumLibrary', 'RequestsLibrary', 'DatabaseLibrary', ] -// Build categories: project keywords + dynamic (from rf-mcp) -// + static curated examples for any always-visible lib not -// covered by dynamic + control. Per-library mix instead of the -// old all-or-nothing fallback so that an env with e.g. Selenium -// installed still gets the curated Browser/Requests/DB examples -// for discovery + auto-install. -const allCategories = computed(() => { - const cats: PaletteCategory[] = [] - - // Project keywords from .robot/.resource files (grouped by file). - // The category for the file the user has currently open is pinned - // to the top of the palette and flagged via `isCurrentFile` so the - // template can render a "current" badge — saves the user from - // scanning every Project: header to find their own keywords. - if (projectKeywords.value.length > 0) { - const currentBase = props.filePath?.split('/').pop() || '' - const byFile = new Map; keywords: string[] }>() - for (const kw of projectKeywords.value) { - const file = kw.file_path.split('/').pop() || kw.file_path - const entry = byFile.get(file) ?? { paths: new Set(), keywords: [] } - entry.paths.add(kw.file_path) - entry.keywords.push(kw.name) - byFile.set(file, entry) - } - const projCats: KeywordCategory[] = [] - for (const [file, entry] of byFile) { - // Match basenames first; if multiple files in different folders - // share a basename, only flag the one whose full path matches. - const isCurrent = file === currentBase - && (props.filePath ? entry.paths.has(props.filePath) || entry.paths.size === 1 : false) - projCats.push({ name: `Project: ${file}`, keywords: entry.keywords, isCurrentFile: isCurrent }) - } - projCats.sort((a, b) => Number(b.isCurrentFile) - Number(a.isCurrentFile)) - cats.push(...projCats) +// --- D1: resource categories (project .robot/.resource files) --------------- +const resourceCategories = computed(() => { + if (projectKeywords.value.length === 0) return [] + // Group by full file_path (unique) so two files sharing a basename in + // different folders don't merge; the relative directory is the subtitle. + const byPath = new Map() + for (const kw of projectKeywords.value) { + const arr = byPath.get(kw.file_path) ?? [] + arr.push(kw.name) + byPath.set(kw.file_path, arr) } + const cats: KeywordCategory[] = [] + for (const [path, keywords] of byPath) { + const slash = path.lastIndexOf('/') + const base = slash >= 0 ? path.slice(slash + 1) : path + const dir = slash >= 0 ? path.slice(0, slash) : '' + cats.push({ + name: base, + keywords, + kind: 'resource', + relPath: dir, + isResourceFile: base.toLowerCase().endsWith('.resource'), + isCurrentFile: path === props.filePath, + }) + } + // Current file pinned first, then alphabetical for stable scanning. + cats.sort((a, b) => { + const c = Number(b.isCurrentFile) - Number(a.isCurrentFile) + if (c !== 0) return c + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + }) + return cats +}) - // Library categories (dynamic from libdoc + static-fallback for - // libs not covered by dynamic). Collected first, sorted by usage, - // then appended — so the libs the user actually uses bubble to the - // top regardless of insertion order. +// --- library categories (dynamic libdoc + static-fallback examples) --------- +const libraryCategories = computed(() => { const libCats: KeywordCategory[] = [] const dynamicLibNames = new Set() for (const [lib, keywords] of dynamicLibraries.value) { - libCats.push({ name: lib, keywords: keywords.map(kw => kw.name) }) + libCats.push({ + name: lib, + keywords: keywords.map(kw => kw.name), + kind: 'library', + imported: props.importedLibraries?.has(lib.toLowerCase()) ?? false, + }) dynamicLibNames.add(lib) } - // Always-visible curated subsets for libs not covered by dynamic. - // BuiltIn is never tagged isExamples (RF auto-imports it so the - // "configure an environment" hint doesn't apply). The rest get - // the (examples) badge. for (const libName of _ALWAYS_VISIBLE_LIBS) { if (dynamicLibNames.has(libName)) continue const staticCat = categories.find(c => c.name === libName) - if (staticCat && 'keywords' in staticCat) { + if (staticCat) { libCats.push({ name: staticCat.name, keywords: staticCat.keywords, + kind: 'library', isExamples: libName !== 'BuiltIn', + imported: props.importedLibraries?.has(libName.toLowerCase()) ?? (libName === 'BuiltIn'), }) } } - // Sort by usage count (desc) — Array.prototype.sort is stable, so - // libs with equal counts keep their existing relative order - // (dynamic libs first in their natural order, then the - // _ALWAYS_VISIBLE_LIBS fallback order). - const counts = props.usageCounts ?? new Map() - libCats.sort((a, b) => (counts.get(b.name) ?? 0) - (counts.get(a.name) ?? 0)) - cats.push(...libCats) - - // Always add Control category at the end - const controlCat = categories.find(c => c.name === 'Control') - if (controlCat) cats.push(controlCat) + return libCats +}) - return cats +// True when the env-backed libdoc returned data — a proxy for "the repo +// has an environment configured" (D6 adaptive default). +const hasEnvData = computed(() => dynamicLibraries.value.size > 0) + +// --- D5 sort + D6 filter state (persisted manual overrides) ----------------- +const sortMode = ref( + parseStoredSort(localStorage.getItem(PALETTE_SORT_LS_KEY)) ?? 'mostUsed', +) +const sortMenuOpen = ref(false) +function setSort(mode: PaletteSort) { + sortMode.value = mode + localStorage.setItem(PALETTE_SORT_LS_KEY, mode) + sortMenuOpen.value = false +} + +// A persisted filter override (null → use the adaptive default). +const filterOverride = ref( + parseStoredFilter(localStorage.getItem(PALETTE_FILTER_LS_KEY)), +) +const filterMenuOpen = ref(false) +const effectiveFilter = computed(() => + filterOverride.value ?? adaptiveDefaultFilter({ + hasEnvData: hasEnvData.value, + file: { importCount: props.fileImportCount ?? 0, stepCount: props.fileStepCount ?? 0 }, + }), +) +function toggleFilter(key: keyof PaletteFilter) { + const next: PaletteFilter = { ...effectiveFilter.value, [key]: !effectiveFilter.value[key] } + filterOverride.value = next + localStorage.setItem(PALETTE_FILTER_LS_KEY, JSON.stringify(next)) +} +function showAllCategories() { + const next: PaletteFilter = { resources: true, importedLibs: true, exampleLibs: true, builtin: true } + filterOverride.value = next + localStorage.setItem(PALETTE_FILTER_LS_KEY, JSON.stringify(next)) +} + +// --- assembled categories (sort → combine → filter) ------------------------- +const allCategories = computed(() => { + const usage = props.usageCounts ?? new Map() + const libs = sortLibraries(libraryCategories.value, sortMode.value, usage) + const resources = sortMode.value === 'alpha' + ? [...resourceCategories.value].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + : resourceCategories.value + return [...resources, ...libs, controlCategory] }) +// Categories after the D6 filter (search applied separately below). +const visibleCategories = computed(() => + applyFilter(allCategories.value as CatLike[], effectiveFilter.value) as PaletteCategory[], +) + +// How many categories the filter is hiding (for the "{n} hidden" affordance). +const hiddenCategoryCount = computed(() => + hiddenCount(allCategories.value as CatLike[], effectiveFilter.value), +) + function collapseAllNow() { expandedCategories.value.clear() } -// Re-collapse on file switch — opening a different test file -// shouldn't expose the expanded view from the previous file. +// Re-collapse on file switch. watch(() => props.filePath, (next, prev) => { if (!next || next === prev) return collapseAllNow() }) - -// Re-collapse on explorer keyword refresh — a fresh ExplorerView -// load (page reload, repo switch, manual refresh) flips -// `keywordsLoaded` from false to true and we want the palette to -// open condensed every time, even if the user had categories -// expanded before the refresh. +// Re-collapse on explorer keyword refresh. watch(() => explorer.keywordsLoaded, (loaded, wasLoaded) => { if (loaded && wasLoaded === false) collapseAllNow() }) const filteredCategories = computed(() => { const q = searchQuery.value.toLowerCase() - if (!q) return allCategories.value - return allCategories.value + if (!q) return visibleCategories.value + return visibleCategories.value .map((cat): PaletteCategory | null => { if ('keywords' in cat) { const kws = cat.keywords.filter(kw => kw.toLowerCase().includes(q)) @@ -459,6 +487,16 @@ const filteredCategories = computed(() => { .filter((cat): cat is PaletteCategory => cat !== null) }) +// D1 — split the rendered list into the pinned "Your resources" section and +// the rest (libraries + control), so the template can draw the section +// header + divider without per-row bookkeeping. +const resourceDisplay = computed(() => + filteredCategories.value.filter((c): c is KeywordCategory => c.kind === 'resource'), +) +const nonResourceDisplay = computed(() => + filteredCategories.value.filter(c => c.kind !== 'resource'), +) + function makeStep(type: StepType = 'keyword'): RobotStep { return { type, keyword: '', args: [], returnVars: [], @@ -487,11 +525,6 @@ function addControlNode(type: StepType) { function onDragStart(event: DragEvent, keyword: string, library?: string) { event.dataTransfer?.setData('application/rf-keyword', keyword) - // Tag with the source library so the canvas drop handler can - // auto-import it if missing. The caller (template) passes the - // category name when applicable; `libraryHintFor` already - // filtered out Project / Control / BuiltIn upstream so we can - // trust the value and write it verbatim. if (library) event.dataTransfer?.setData('application/rf-library', library) event.dataTransfer!.effectAllowed = 'copy' } @@ -507,6 +540,47 @@ function onControlDragStart(event: DragEvent, type: StepType) {

{{ t('flowEditor.palette') || 'Keywords' }}

+ +
+ +
+ +
+
+ +
+ +
+ +
+
@@ -516,10 +590,15 @@ function onControlDragStart(event: DragEvent, type: StepType) { class="palette-search" :placeholder="t('flowEditor.searchKeywords') || 'Search keywords...'" /> + +
+ {{ t('flowEditor.filter.hidingCategories', { count: hiddenCategoryCount }) }} + +
- {{ selectedKeyword.name }} + {{ selectedKeyword.name }}
+
+ + + +
- {{ isCategoryOpen(cat.name) ? '\u25BC' : '\u25B6' }} + {{ isCategoryOpen(cat.name) ? '▼' : '▶' }} {{ cat.name }} - {{ t('flowEditor.currentFileCategoryBadge') }}
{{ kw }} ({{ getKeywordArgs(kw).length }}) + lib @@ -594,6 +718,7 @@ function onControlDragStart(event: DragEvent, type: StepType) { v-for="item in cat.items" :key="item.label" :class="['palette-item', 'palette-item-control', { selected: isSelected(item.label) }]" + :title="item.label" draggable="true" @dragstart="onControlDragStart($event, item.type)" @click="selectKeyword(item.label, item.type)" @@ -623,8 +748,6 @@ function onControlDragStart(event: DragEvent, type: StepType) { } .palette-header { padding: 10px 12px 6px; -} -.palette-header { display: flex; align-items: center; justify-content: space-between; @@ -650,6 +773,83 @@ function onControlDragStart(event: DragEvent, type: StepType) { .palette-action-btn:hover { background: #e2e8f0; } +.palette-action-btn--on { + color: var(--color-primary, #3B7DD8); +} +.palette-menu-wrap { + position: relative; +} +.palette-menu { + position: absolute; + top: 100%; + right: 0; + z-index: 20; + margin-top: 2px; + min-width: 150px; + background: #fff; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + padding: 4px; +} +.palette-menu--wide { + min-width: 210px; +} +.palette-menu-item { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + text-align: left; + background: none; + border: none; + cursor: pointer; + font-size: 12px; + padding: 5px 6px; + border-radius: 4px; + color: var(--color-text, #1A2D50); +} +.palette-menu-item:hover { + background: #EBF4FF; +} +.palette-menu-item--active { + font-weight: 600; + color: var(--color-primary, #3B7DD8); +} +.palette-menu-item--check { + cursor: pointer; +} +.palette-menu-item--check input { + margin: 0; +} +.palette-menu-check { + width: 12px; + color: var(--color-primary, #3B7DD8); +} +.palette-hiding-hint { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin: 0 10px 8px; + padding: 4px 8px; + font-size: 11px; + color: var(--color-text-muted, #5A6380); + background: #eef2f7; + border-radius: 6px; +} +.palette-hiding-clear { + background: none; + border: none; + cursor: pointer; + font-size: 11px; + font-weight: 600; + color: var(--color-primary, #3B7DD8); + padding: 0; +} +.palette-hiding-clear:hover { + text-decoration: underline; +} .palette-add-bar { display: flex; align-items: center; @@ -726,6 +926,32 @@ function onControlDragStart(event: DragEvent, type: StepType) { overflow-y: auto; padding: 0 6px 12px; } +/* D1 — "Your resources" section header */ +.palette-section-label { + display: flex; + align-items: center; + gap: 5px; + padding: 8px 6px 4px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-navy, #1A2D50); +} +.palette-section-glyph { + font-size: 12px; +} +.palette-section-info { + margin-left: auto; + color: var(--color-text-muted, #5A6380); + cursor: help; + font-style: normal; +} +.palette-section-divider { + height: 1px; + background: var(--color-border, #e2e8f0); + margin: 6px 4px 4px; +} .category-header { display: flex; align-items: center; @@ -743,6 +969,7 @@ function onControlDragStart(event: DragEvent, type: StepType) { color: var(--color-text-muted, #5A6380); width: 12px; text-align: center; + flex-shrink: 0; } .category-name { font-size: 10px; @@ -751,6 +978,35 @@ function onControlDragStart(event: DragEvent, type: StepType) { letter-spacing: 0.5px; color: var(--color-text-muted, #5A6380); flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* D1 — resource file header: NOT shouty, basename + path subtitle */ +.category-file-glyph { + font-size: 12px; + flex-shrink: 0; +} +.category-file { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} +.category-file-name { + font-size: 12px; + font-weight: 600; + color: var(--color-navy, #1A2D50); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.category-file-path { + font-size: 9px; + color: var(--color-text-muted, #5A6380); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .category-count { font-size: 9px; @@ -758,25 +1014,19 @@ function onControlDragStart(event: DragEvent, type: StepType) { background: #e2e8f0; padding: 1px 5px; border-radius: 8px; + flex-shrink: 0; } -/* Static-fallback hint: "BuiltIn (examples)", "Browser (examples)". - Signals that the listed keywords are a curated subset, not the - full library — kicks in when the dynamic libdoc introspection - returned nothing (no env, library not installed, rf-mcp off). */ .category-examples-badge { font-size: 9px; font-style: italic; color: var(--color-accent, #D4883E); margin-right: 4px; + flex-shrink: 0; } -/* Highlights the Project: category for the file the user has open - right now — pinned to the top of the palette and tinted so it - stands out from the other Project: entries. */ .palette-category--current .category-header { background: color-mix(in srgb, var(--color-primary, #3B7DD8) 8%, transparent); } -.palette-category--current .category-name { - font-weight: 700; +.palette-category--current .category-file-name { color: var(--color-primary, #3B7DD8); } .category-current-badge { @@ -789,6 +1039,7 @@ function onControlDragStart(event: DragEvent, type: StepType) { padding: 1px 6px; border-radius: 8px; margin-right: 4px; + flex-shrink: 0; } .palette-item { display: flex; @@ -820,17 +1071,14 @@ function onControlDragStart(event: DragEvent, type: StepType) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; + min-width: 0; } .palette-item-argcount { font-size: 10px; color: var(--color-text-muted, #5A6380); flex-shrink: 0; } - -/* Dimmed appearance for keywords whose owning library isn't - imported in the file yet. Hover restores opacity so the user can - read the name; the "+ lib" badge signals what will happen on - pick. The auto-import itself runs in FlowEditor. */ .palette-item--not-imported { opacity: 0.55; border-style: dashed; @@ -852,7 +1100,6 @@ function onControlDragStart(event: DragEvent, type: StepType) { letter-spacing: 0.04em; text-transform: uppercase; } - .palette-item:active { cursor: grabbing; } @@ -862,6 +1109,9 @@ function onControlDragStart(event: DragEvent, type: StepType) { .palette-item-control .palette-icon { color: #E8A838; } +.palette-icon { + flex-shrink: 0; +} .palette-empty { padding: 16px 12px; color: var(--color-text-muted, #5A6380); diff --git a/frontend/src/components/editor/flow/paletteView.ts b/frontend/src/components/editor/flow/paletteView.ts new file mode 100644 index 00000000..c10027b4 --- /dev/null +++ b/frontend/src/components/editor/flow/paletteView.ts @@ -0,0 +1,162 @@ +/** + * UX D5 / D6 (ux-flow-editor-resources.md) — pure view-state helpers for the + * Flow Editor keyword palette: the "what's shown" filter (incl. its adaptive + * default) and the sort control. Kept framework-free so the heuristic and the + * ordering are unit-testable without mounting the component. + */ + +/** The four toggle-able category buckets in the D6 filter. */ +export interface PaletteFilter { + /** Project `.robot` / `.resource` keyword files ("Your resources"). */ + resources: boolean + /** Real/installed libraries (libdoc-backed), BuiltIn excluded. */ + importedLibs: boolean + /** Curated example libraries that aren't installed in the env. */ + exampleLibs: boolean + /** The always-available BuiltIn library. */ + builtin: boolean +} + +export type PaletteSort = 'mostUsed' | 'alpha' | 'importedFirst' + +/** localStorage keys for the persisted manual overrides. */ +export const PALETTE_FILTER_LS_KEY = 'roboscope.flowPalette.filter' +export const PALETTE_SORT_LS_KEY = 'roboscope.flowPalette.sort' + +/** + * D6 "sophisticated file" thresholds. A file counts as a real/sophisticated + * test (vs a fresh "mini" file still being explored) when it already imports + * at least one Library/Resource OR has a non-trivial number of steps. Kept in + * one place so they're tunable and pinned by a unit test. + */ +export const SOPHISTICATED_MIN_IMPORTS = 1 +export const SOPHISTICATED_MIN_STEPS = 5 + +export interface FileShape { + /** Count of `Library` + `Resource` imports in the open file. */ + importCount: number + /** Total steps across the open file's test cases + keywords. */ + stepCount: number +} + +/** True when the open file looks like a real test rather than a fresh stub. */ +export function isSophisticatedFile(f: FileShape): boolean { + return f.importCount >= SOPHISTICATED_MIN_IMPORTS || f.stepCount >= SOPHISTICATED_MIN_STEPS +} + +const ALL_VISIBLE: PaletteFilter = { + resources: true, + importedLibs: true, + exampleLibs: true, + builtin: true, +} + +const IMPORTED_ONLY: PaletteFilter = { + resources: true, + importedLibs: true, + exampleLibs: false, // hide the not-installed example noise + builtin: true, +} + +/** + * D6 adaptive default (decided 2026-06-18). For a repo that already has an + * environment AND a sophisticated open file, default to imported-only (hide + * example libs). Env-less repos and fresh/mini files default to showing + * everything so beginners can discover the example-library catalogue. + */ +export function adaptiveDefaultFilter(opts: { + hasEnvData: boolean + file: FileShape +}): PaletteFilter { + if (opts.hasEnvData && isSophisticatedFile(opts.file)) return { ...IMPORTED_ONLY } + return { ...ALL_VISIBLE } +} + +/** Parse a persisted filter override, tolerating malformed/legacy values. */ +export function parseStoredFilter(raw: string | null): PaletteFilter | null { + if (!raw) return null + try { + const o = JSON.parse(raw) as Partial + if ( + typeof o?.resources === 'boolean' && + typeof o?.importedLibs === 'boolean' && + typeof o?.exampleLibs === 'boolean' && + typeof o?.builtin === 'boolean' + ) { + return { resources: o.resources, importedLibs: o.importedLibs, exampleLibs: o.exampleLibs, builtin: o.builtin } + } + } catch { + /* fall through */ + } + return null +} + +export function parseStoredSort(raw: string | null): PaletteSort | null { + return raw === 'mostUsed' || raw === 'alpha' || raw === 'importedFirst' ? raw : null +} + +/** The minimal category shape these helpers reason about. */ +export interface CatLike { + name: string + kind: 'resource' | 'library' | 'control' + isExamples?: boolean + isCurrentFile?: boolean + /** Whether the owning library is imported in the open file (for sort/filter). */ + imported?: boolean +} + +/** Which filter bucket a category belongs to (null = never filtered, e.g. Control). */ +export function bucketOf(cat: CatLike): keyof PaletteFilter | null { + if (cat.kind === 'resource') return 'resources' + if (cat.kind === 'control') return null + if (cat.name === 'BuiltIn') return 'builtin' + if (cat.isExamples) return 'exampleLibs' + return 'importedLibs' +} + +/** Apply the filter; Control is always kept. */ +export function applyFilter(cats: T[], filter: PaletteFilter): T[] { + return cats.filter((c) => { + const b = bucketOf(c) + return b === null ? true : filter[b] + }) +} + +/** + * Count how many categories the filter is currently hiding (for the + * "{count} hidden · show all" affordance). Control never counts. + */ +export function hiddenCount(cats: CatLike[], filter: PaletteFilter): number { + return cats.reduce((n, c) => { + const b = bucketOf(c) + return b !== null && !filter[b] ? n + 1 : n + }, 0) +} + +/** + * Order LIBRARY categories per the sort mode. Resources and Control are sorted + * by the caller into their own pinned positions — this only governs the + * library block. `usage` maps library name → usage count (most-used sort). + */ +export function sortLibraries( + libs: T[], + mode: PaletteSort, + usage: Map, +): T[] { + const out = [...libs] + if (mode === 'alpha') { + out.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + } else if (mode === 'importedFirst') { + // Imported (in-file) / real libs first, examples last; ties by usage desc. + out.sort((a, b) => { + const ai = a.isExamples ? 1 : 0 + const bi = b.isExamples ? 1 : 0 + if (ai !== bi) return ai - bi + return (usage.get(b.name) ?? 0) - (usage.get(a.name) ?? 0) + }) + } else { + // mostUsed (default) — stable usage-desc; preserves natural order on ties. + out.sort((a, b) => (usage.get(b.name) ?? 0) - (usage.get(a.name) ?? 0)) + } + return out +} diff --git a/frontend/src/components/editor/flow/resourcePath.ts b/frontend/src/components/editor/flow/resourcePath.ts new file mode 100644 index 00000000..7b4dd905 --- /dev/null +++ b/frontend/src/components/editor/flow/resourcePath.ts @@ -0,0 +1,29 @@ +/** + * Epic RES — compute the Robot Framework `Resource` import path for a keyword + * sourced from another file in the repo. + * + * RF resolves `Resource ` relative to the *importing* file's + * directory. Given the open file and the resource file (both repo-relative, + * POSIX), return the path from the open file's directory to the resource — + * e.g. open `tests/login.robot` + resource `resources/common.resource` → + * `../resources/common.resource`; same directory → just the basename. + */ +export function resourceImportPath(openFile: string, resourceFile: string): string { + const norm = (p: string) => p.replace(/\\/g, '/').replace(/^\.\//, '') + const open = norm(openFile) + const target = norm(resourceFile) + if (!open) return target // no open-file context → use as-is + + const fromDir = open.split('/').slice(0, -1) // directory segments of the open file + const toParts = target.split('/') + + // Longest common directory prefix (stop before the resource's own basename). + let i = 0 + while (i < fromDir.length && i < toParts.length - 1 && fromDir[i] === toParts[i]) { + i++ + } + const ups = fromDir.length - i + const down = toParts.slice(i) + const segments = [...Array(ups).fill('..'), ...down] + return segments.join('/') || target +} diff --git a/frontend/src/components/execution/RunDetailPanel.vue b/frontend/src/components/execution/RunDetailPanel.vue index a2014a31..e51b4970 100644 --- a/frontend/src/components/execution/RunDetailPanel.vue +++ b/frontend/src/components/execution/RunDetailPanel.vue @@ -198,6 +198,7 @@ onUnmounted(() => { // --- AI Failure Analysis --- const analysisError = ref('') +const verbosity = ref<'concise' | 'standard' | 'detailed'>('standard') // Scope the shared store analysis to THIS run's report. The store holds a // single `analysisJob`; without this guard an analysis produced for another @@ -212,7 +213,7 @@ async function startAnalysis() { if (!reportId.value) return analysisError.value = '' try { - await aiStore.analyzeFailures(reportId.value, undefined, locale.value) + await aiStore.analyzeFailures(reportId.value, undefined, locale.value, verbosity.value) } catch (e: unknown) { analysisError.value = extractErrorDetail(e, 'Analysis failed') } @@ -515,6 +516,14 @@ watch(() => props.run.status, (newStatus, oldStatus) => {
+ {{ t('reportDetail.analysis.analyzeButton') }} diff --git a/frontend/src/composables/useFeatureFlags.ts b/frontend/src/composables/useFeatureFlags.ts new file mode 100644 index 00000000..49131d6d --- /dev/null +++ b/frontend/src/composables/useFeatureFlags.ts @@ -0,0 +1,65 @@ +/** + * Epic GOV — deployment feature flags shared across the app. + * + * Singleton: the resolved flag set is fetched once from `/config/features` + * and cached. Flags change only via an admin settings edit or a deployment + * restart (ENV override), so there is no polling — callers can `refresh()` + * after an admin saves settings. + * + * Default-enabled semantics: `isEnabled` returns true while loading or for an + * unknown flag, so UI affordances never flicker hidden and a fetch failure + * degrades to "feature visible" (the server still enforces — UI hiding is + * convenience only). + */ +import { ref } from 'vue' +import { getFeatures } from '@/api/governance.api' + +const flags = ref>({}) +const locked = ref>({}) +const loaded = ref(false) +let inflight: Promise | null = null + +async function refresh(): Promise { + // Auth gate: skip the fetch with no token. An unauthenticated call to + // /config/features returns 401, and the axios interceptor reacts to 401 + // with a full page reload — which would re-mount the layout and re-invoke + // this composable, looping. (CLAUDE.md: "Singleton composables + auth".) + if (!localStorage.getItem('access_token')) return + try { + const res = await getFeatures() + flags.value = res.flags + locked.value = res.locked + loaded.value = true + } catch { + // Leave defaults (everything enabled); the server enforces regardless. + } +} + +export function useFeatureFlags() { + if (!loaded.value && !inflight) { + inflight = refresh().finally(() => { + inflight = null + }) + } + + /** True unless the flag is explicitly disabled (default-enabled). */ + function isEnabled(flag: string): boolean { + return flags.value[flag] !== false + } + + /** True when the flag is locked by an ENV override (non-editable in UI). */ + function isLocked(flag: string): boolean { + return locked.value[flag] === true + } + + /** Forget the cached flags (call on logout so the next login refetches for + * the new user — flags could differ if an admin changed them). */ + function reset() { + loaded.value = false + flags.value = {} + locked.value = {} + inflight = null + } + + return { isEnabled, isLocked, refresh, reset, loaded } +} diff --git a/frontend/src/docs/content/de.ts b/frontend/src/docs/content/de.ts index 1d8a6d1b..6c583985 100644 --- a/frontend/src/docs/content/de.ts +++ b/frontend/src/docs/content/de.ts @@ -2101,6 +2101,25 @@ Login Works versteckt werden, sobald SSO vollständig ausgerollt ist.

`, tip: 'Führen Sie die Dry-Run-Probe immer vor dem Speichern und vor dem Rollout an Endnutzer aus. Sie fängt 90 % aller Fehlkonfigurationen (falscher Issuer, fehlender Scope, nicht erreichbarer JWKS) ab, ohne Endnutzer zu beeinträchtigen.' + }, + { + id: 'feature-governance', + title: 'Feature-Governance (Paketverwaltung sperren)', + content: `

Bei einer gemeinsam genutzten oder entfernten Installation, bei der die Python-Umgebungen zentral verwaltet werden, können Sie die Paketverwaltung deaktivieren, sodass Endnutzer in der verwalteten Umgebung keine Pakete installieren, deinstallieren oder aktualisieren, keine Docker-Images bauen und kein rfbrowser init ausführen können.

+

So deaktivieren Sie die Funktion

+
    +
  • Über die Oberfläche — setzen Sie unter Einstellungen > Allgemein > features die Option features.packageManagement auf Nein.
  • +
  • Über das Deployment (harte Sperre) — setzen Sie die Umgebungsvariable ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT=false auf dem Server. Diese hat Vorrang vor dem In-App-Schalter und zeigt ihn als 🔒 gesperrt (nicht editierbar) an. Eine Änderung der Umgebungsvariable wird beim nächsten Neustart wirksam.
  • +
+

Die Auflösungsreihenfolge ist Umgebungsvariable → Datenbank-Einstellung → Standard (aktiviert).

+

Was sich ändert, wenn die Funktion deaktiviert ist

+
    +
  • Die Umgebungsseite blendet die Steuerelemente zum Installieren / Deinstallieren / Aktualisieren / Bauen aus und zeigt einen schreibgeschützten Hinweis; die Liste der installierten Pakete bleibt sichtbar.
  • +
  • Die zugehörigen API-Endpunkte werden serverseitig abgewiesen (HTTP 403) — die Sperre lässt sich nicht über die API umgehen, und die Blockierung wird im Audit-Log protokolliert.
  • +
+

Mindest-Rolle

+

Wenn die Paketverwaltung aktiviert bleibt, können Sie unter den Einstellungen features.packageManagement.role.* weiterhin die für jede Operation erforderliche Mindest-Rolle anheben (Standard Editor).

`, + tip: 'Verwenden Sie die Sperre über die Umgebungsvariable (nicht nur den In-App-Schalter) bei Installationen, bei denen Endnutzer Umgebungen niemals anfassen sollen — sie lässt sich nicht aus der Anwendung heraus ändern.' } ] }, @@ -2392,6 +2411,35 @@ Login Works Die vollst\u00E4ndige API-Dokumentation mit allen Endpunkten, Parametern und Antwortformaten finden Sie in der interaktiven Swagger UI unter /api/v1/docs. +

` + }, + { + id: 'i18n', + title: 'Sprachunterstützung', + content: ` +

+ RoboScope unterstützt mehrere Oberflächensprachen: +

+ + + + + + + + + + + +
CodeSprache
enEnglish (Englisch)
deDeutsch
frFrançais (Französisch)
esEspañol (Spanisch)
zh中文 (Vereinfachtes Chinesisch)
+

+ Zum Wechseln der Sprache nutzen Sie den Sprachauswähler in der + Kopfzeile der Anwendung. Die gewählte Sprache wird im Local Storage Ihres + Browsers gespeichert und bleibt über Sitzungen hinweg erhalten. Alle + Beschriftungen, Schaltflächen und Meldungen passen sich der gewählten + Sprache an. Diese Dokumentation ist in Englisch, Deutsch, Französisch und + Spanisch verfasst; ist die Oberfläche auf Chinesisch eingestellt, wird die + Dokumentation auf Englisch angezeigt.

` } ] diff --git a/frontend/src/docs/content/en.ts b/frontend/src/docs/content/en.ts index d6bacea4..92aacb81 100644 --- a/frontend/src/docs/content/en.ts +++ b/frontend/src/docs/content/en.ts @@ -348,6 +348,36 @@ const en: DocsContent = [

`, tip: 'Always click "Save N changes" before "Sync" — pulling first can overwrite or refuse with a merge error if you have local edits.' }, + { + id: 'branch-switching', + title: 'Branch Switching & Auto-Sync', + content: ` +

Branch switching

+

+ Every Git project card shows a branch dropdown that lets you + switch between the available branches. Select a different branch to check it + out — useful for testing feature branches or comparing results across branches. +

+

Auto-Sync checkbox

+

+ The Auto-Sync checkbox on each project card controls whether + the repository is synced automatically before test runs. Enable it for CI/CD + flows where you always want to test the latest code. +

+

Pre-run sync

+

+ Enable Pre-run sync on a repository when every run must use + the very latest commit. RoboScope performs a synchronous git pull + right before the runner starts, with a 60 s timeout. It is off by default + and adds a few seconds per run; it combines with Auto-Sync — enable either, + both, or neither. +

+

+ If the pull fails (network, conflict, timeout), the run still starts with the + state on disk; the error is logged and the next Auto-Sync retries. +

`, + tip: 'Pre-run sync guarantees the latest commit per run; Auto-Sync keeps the checkout fresh in the background. Use Pre-run sync for CI-critical suites where staleness would be a real bug.' + }, { id: 'library-check', title: 'Library Check (Package Manager)', @@ -2170,6 +2200,37 @@ Login Works password form once SSO is fully rolled out.

`, tip: 'Always run the Dry-Run probe before saving and before rolling out to users. The check catches 90% of misconfigurations (wrong issuer, missing scope, unreachable JWKS) without affecting end users.' + }, + { + id: 'feature-governance', + title: 'Feature Governance (locking down package management)', + content: ` +

+ On a shared or remote install where Python environments are administered + centrally, you can disable package management so end users + cannot install, uninstall, upgrade packages, build Docker images, or run + rfbrowser init against the managed environment. +

+

How to disable it

+
    +
  • From the UI — under Settings > General > features, set features.packageManagement to No.
  • +
  • From the deployment (hard lock) — set the environment variable ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT=false on the server. This wins over the in-app toggle and shows it as 🔒 locked (non-editable). Changing an environment variable takes effect on the next restart.
  • +
+

+ Resolution precedence is environment variable → database setting → default (enabled). +

+

What changes when it's off

+
    +
  • The Environments page hides install / uninstall / upgrade / build controls and shows a read-only notice; the installed-package list stays visible.
  • +
  • The corresponding API endpoints are refused server-side (HTTP 403) — the lock cannot be bypassed via the API, and the block is recorded in the Audit Log.
  • +
+

Role floor

+

+ When package management is left on, you can still raise the minimum + role required for each operation under the features.packageManagement.role.* + settings (default Editor). +

`, + tip: 'Use the environment-variable lock (not just the in-app toggle) on installs where end users should never touch environments — it can’t be changed from inside the app.' } ] }, @@ -2509,6 +2570,43 @@ Login Works across sessions. All UI labels, buttons, and messages adapt to the selected language. This documentation is written in English, German, French, and Spanish; when the interface is set to Chinese, the docs fall back to English. +

` + }, + { + id: 'api-access', + title: 'API Access', + content: ` +

+ RoboScope exposes a full REST API under /api/v1/. Everything the + interface does is available programmatically. +

+

Authentication

+

+ The API uses JWT bearer tokens. Request a token from the login endpoint: +

+

+ POST /api/v1/auth/login with {"email": "...", "password": "..."} +

+

+ Send the returned token as an Authorization: Bearer <token> + header on every request. +

+

Key endpoints

+ + + + + + + + + + +
EndpointDescription
GET /api/v1/reposList all repositories
POST /api/v1/runsStart a new run
GET /api/v1/reportsList reports
GET /api/v1/stats/kpisFetch KPI data
+

+ The full API documentation with all endpoints, parameters, and response + formats is available in the interactive Swagger UI at + /api/v1/docs.

` } ] diff --git a/frontend/src/docs/content/es.ts b/frontend/src/docs/content/es.ts index a541d113..b324bd0d 100644 --- a/frontend/src/docs/content/es.ts +++ b/frontend/src/docs/content/es.ts @@ -2180,6 +2180,26 @@ Login Works que el SSO esté completamente desplegado.

`, tip: 'Ejecute siempre la sonda Dry-Run antes de guardar y antes de desplegar a los usuarios. Detecta el 90 % de los errores de configuración (emisor incorrecto, scope faltante, JWKS inaccesible) sin afectar a los usuarios finales.' + }, + { + id: 'feature-governance', + title: 'Gobernanza de funciones (bloqueo de la gestión de paquetes)', + content: ` +

En una instalación compartida o remota donde los entornos de Python se administran de forma centralizada, puede desactivar la gestión de paquetes para que los usuarios finales no puedan instalar, desinstalar ni actualizar paquetes, crear imágenes de Docker ni ejecutar rfbrowser init sobre el entorno gestionado.

+

Cómo desactivarla

+
    +
  • Desde la interfaz — en Ajustes > General > features, establezca features.packageManagement en No.
  • +
  • Desde el despliegue (bloqueo permanente) — establezca la variable de entorno ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT=false en el servidor. Esta tiene prioridad sobre el conmutador de la aplicación y la muestra como 🔒 bloqueada (no editable). Cambiar una variable de entorno surte efecto en el siguiente reinicio.
  • +
+

El orden de prioridad de resolución es variable de entorno → ajuste de la base de datos → valor predeterminado (habilitado).

+

Qué cambia cuando está desactivada

+
    +
  • La página de Entornos oculta los controles de instalar / desinstalar / actualizar / crear y muestra un aviso de solo lectura; la lista de paquetes instalados permanece visible.
  • +
  • Los endpoints de API correspondientes se rechazan en el servidor (HTTP 403) — el bloqueo no puede eludirse a través de la API, y el bloqueo queda registrado en el Registro de auditoría.
  • +
+

Rol mínimo

+

Cuando la gestión de paquetes se deja activada, todavía puede elevar el rol mínimo requerido para cada operación en los ajustes features.packageManagement.role.* (valor predeterminado Editor).

`, + tip: 'Use el bloqueo mediante variable de entorno (no solo el conmutador de la aplicación) en instalaciones donde los usuarios finales nunca deban tocar los entornos — no puede modificarse desde dentro de la aplicación.' } ] }, @@ -2470,6 +2490,28 @@ Login Works franc\u00E9s y espa\u00F1ol; cuando la interfaz est\u00E1 en chino, la documentaci\u00F3n recurre al ingl\u00E9s.

` + }, + { + id: 'api-access', + title: 'Acceso a la API', + content: ` +

RoboScope expone una API REST completa bajo /api/v1/. Todo lo que hace la interfaz est\u00E1 disponible de forma program\u00E1tica.

+

Autenticaci\u00F3n

+

La API utiliza tokens bearer JWT. Solicite un token desde el endpoint de inicio de sesi\u00F3n:

+

POST /api/v1/auth/login con {"email": "...", "password": "..."}

+

Env\u00EDe el token devuelto como una cabecera Authorization: Bearer <token> en cada solicitud.

+

Endpoints principales

+ + + + + + + + +
EndpointDescripci\u00F3n
GET /api/v1/reposListar todos los repositorios
POST /api/v1/runsIniciar una nueva ejecuci\u00F3n
GET /api/v1/reportsListar informes
GET /api/v1/stats/kpisObtener datos de KPI
+

La documentaci\u00F3n completa de la API con todos los endpoints, par\u00E1metros y formatos de respuesta est\u00E1 disponible en la Swagger UI interactiva en /api/v1/docs.

`, + tip: 'La Swagger UI en /api/v1/docs es la referencia en vivo: cada endpoint, par\u00E1metro y esquema, generado a partir del servidor en ejecuci\u00F3n.' } ] }, diff --git a/frontend/src/docs/content/fr.ts b/frontend/src/docs/content/fr.ts index b20b0ee1..06e2b1b5 100644 --- a/frontend/src/docs/content/fr.ts +++ b/frontend/src/docs/content/fr.ts @@ -2140,6 +2140,25 @@ Login Works une fois le SSO entièrement déployé.

`, tip: 'Lancez toujours la sonde Dry-Run avant d’enregistrer et avant le déploiement aux utilisateurs. Elle détecte 90 % des erreurs de configuration (mauvais émetteur, scope manquant, JWKS inaccessible) sans affecter les utilisateurs finaux.' + }, + { + id: 'feature-governance', + title: 'Gouvernance des fonctionnalités (verrouillage de la gestion des paquets)', + content: `

Sur une installation partagée ou distante où les environnements Python sont administrés de manière centralisée, vous pouvez désactiver la gestion des paquets afin que les utilisateurs finaux ne puissent pas installer, désinstaller ou mettre à niveau des paquets, construire des images Docker, ni exécuter rfbrowser init sur l’environnement géré.

+

Comment la désactiver

+
    +
  • Depuis l’interface — sous Paramètres > Général > features, réglez features.packageManagement sur Non.
  • +
  • Depuis le déploiement (verrouillage strict) — définissez la variable d’environnement ROBOSCOPE_FEATURE_PACKAGE_MANAGEMENT=false sur le serveur. Elle l’emporte sur la bascule intégrée à l’application et l’affiche comme 🔒 verrouillée (non modifiable). La modification d’une variable d’environnement prend effet au prochain redémarrage.
  • +
+

L’ordre de résolution est variable d’environnement → paramètre de la base de données → valeur par défaut (activé).

+

Ce qui change lorsque c’est désactivé

+
    +
  • La page Environnements masque les contrôles d’installation / désinstallation / mise à niveau / construction et affiche un avis en lecture seule ; la liste des paquets installés reste visible.
  • +
  • Les points de terminaison API correspondants sont refusés côté serveur (HTTP 403) — le verrouillage ne peut pas être contourné via l’API, et le blocage est consigné dans le journal d’audit.
  • +
+

Rôle minimum

+

Lorsque la gestion des paquets reste activée, vous pouvez tout de même relever le rôle minimum requis pour chaque opération sous les paramètres features.packageManagement.role.* (par défaut Éditeur).

`, + tip: 'Utilisez le verrouillage par variable d’environnement (et pas seulement la bascule intégrée à l’application) sur les installations où les utilisateurs finaux ne doivent jamais toucher aux environnements — il ne peut pas être modifié depuis l’application.' } ] }, @@ -2424,6 +2443,28 @@ Login Works anglais, allemand, fran\u00E7ais et espagnol ; lorsque l\u2019interface est r\u00E9gl\u00E9e sur le chinois, la documentation se rabat sur l\u2019anglais.

` + }, + { + id: 'api-access', + title: 'Acc\u00e8s \u00e0 l\u2019API', + content: ` +

RoboScope expose une API REST compl\u00e8te sous /api/v1/. Tout ce que fait l\u2019interface est disponible de mani\u00e8re programmatique.

+

Authentification

+

L\u2019API utilise des jetons bearer JWT. Demandez un jeton aupr\u00e8s du point de terminaison de connexion\u00a0:

+

POST /api/v1/auth/login avec {"email": "...", "password": "..."}

+

Envoyez le jeton retourn\u00e9 dans un en-t\u00eate Authorization: Bearer <token> \u00e0 chaque requ\u00eate.

+

Points de terminaison principaux

+ + + + + + + + +
Point de terminaisonDescription
GET /api/v1/reposLister tous les d\u00e9p\u00f4ts
POST /api/v1/runsD\u00e9marrer une nouvelle ex\u00e9cution
GET /api/v1/reportsLister les rapports
GET /api/v1/stats/kpisR\u00e9cup\u00e9rer les donn\u00e9es KPI
+

La documentation compl\u00e8te de l\u2019API, avec tous les points de terminaison, param\u00e8tres et formats de r\u00e9ponse, est disponible dans l\u2019interface interactive Swagger UI \u00e0 l\u2019adresse /api/v1/docs.

`, + tip: 'L\u2019interface Swagger UI \u00e0 /api/v1/docs est la r\u00e9f\u00e9rence en direct\u00a0: chaque point de terminaison, param\u00e8tre et sch\u00e9ma, g\u00e9n\u00e9r\u00e9 \u00e0 partir du serveur en cours d\u2019ex\u00e9cution.' } ] }, diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 43fee2c2..22c99e02 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -386,6 +386,10 @@ export default { title: 'Neuen Run starten', repository: 'Projekt', selectRepo: 'Bitte wählen...', + tagsInclude: 'Tags einschließen', + tagsExclude: 'Tags ausschließen', + tagsPlaceholder: 'smoke, regression', + tagsHint: 'Kommagetrennt. Führt nur Tests mit diesen Tags aus (robot --include / --exclude).', targetPath: 'Zielpfad', targetPlaceholder: 'tests/ oder tests/login.robot', timeout: 'Timeout (Sekunden)', @@ -453,6 +457,7 @@ export default { shippedWithRoboscopeTitle: 'Eine Version dieser Bibliothek liegt direkt bei RoboScope bei und wird in jedes neue Projekt-venv automatisch eingespielt. Klick auf „Installieren" verwendet die mitgelieferte Variante. Mit explizit gepinnter Version wird stattdessen PyPI verwendet (sobald dort verfügbar).', conflictInstalled: 'Andere Variante installiert', noPackages: 'Keine Packages installiert.', + managedByAdmin: 'Die Paketverwaltung wird von Ihrer Administration verwaltet. Pakete werden schreibgeschützt angezeigt.', showAllPip: 'Alle installierten Pakete anzeigen ({count})', variables: 'Variablen', noVariables: 'Keine Variablen definiert.', @@ -621,6 +626,7 @@ export default { analysis: { title: 'KI-Fehleranalyse', analyzeButton: 'Fehler analysieren', + verbosity: { label: 'Detailgrad', concise: 'Knapp', standard: 'Standard', detailed: 'Ausführlich' }, noProvider: 'Konfigurieren Sie einen KI-Anbieter in den Einstellungen, um die Fehleranalyse zu aktivieren.', analyzing: 'Testfehler werden analysiert... Dies kann 10-30 Sekunden dauern.', failed: 'Analyse fehlgeschlagen', @@ -817,6 +823,7 @@ export default { }, settings: { title: 'Einstellungen', + lockedByEnv: 'Per Umgebungsvariable gesetzt — von der Server-Administration verwaltet.', general: 'Allgemein', users: 'Benutzer', userManagement: 'Benutzerverwaltung', @@ -1124,6 +1131,7 @@ export default { providerNamePlaceholder: 'z.B. Mein OpenAI, Lokales Llama', leaveBlankKeep: 'leer lassen, um aktuellen beizubehalten', ollamaHint: 'Ollama läuft lokal — kein API-Key erforderlich. Stelle sicher, dass Ollama auf deinem Rechner läuft (Standard: http://localhost:11434). Verwende jedes Modell, das du heruntergeladen hast, z.B. "ollama pull mistral".', + litellmHint: 'LiteLLM ist ein OpenAI-kompatibles Gateway. Trage als Base URL den Gateway-Endpunkt ein (z.B. http://litellm.internal:4000) und gib den Modellnamen an, den dein Gateway bereitstellt. Der API-Key ist dein Gateway-Key (leer lassen, wenn das Gateway keinen benötigt).', ollamaNoKey: 'nicht nötig für Ollama', ollamaModelHint: 'Modellname eingeben, z.B. mistral:latest', confirmDeleteProvider: 'Anbieter "{name}" löschen?', @@ -1270,6 +1278,30 @@ export default { examplesCategoryHint: 'Nur eine kuratierte Auswahl wird gezeigt, weil die dynamische Library-Introspektion keine Daten liefert — meist weil dem Repository kein Environment zugeordnet ist oder die Library im Environment nicht installiert ist. Ordne ein Environment zu, um die vollständige Keyword-Liste zu sehen.', currentFileCategoryBadge: 'aktuelle Datei', currentFileCategoryHint: 'Keywords aus der Datei, die du gerade bearbeitest.', + resourcesSectionLabel: 'Deine Ressourcen', + resourcesSectionHint: 'Keywords aus .robot-/.resource-Dateien in diesem Repository. Beim Einfügen wird der passende Resource-Import automatisch ergänzt.', + resourcePanelLabel: 'Ressourcen', + librariesPanelLabel: 'Bibliotheken', + importAdded: { + resourceTitle: 'Ressource importiert', + libraryTitle: 'Bibliothek importiert', + message: '{name} wurde zu *** Settings *** hinzugefügt, damit das Keyword zur Laufzeit auflöst.', + }, + sort: { + title: 'Keywords sortieren', + mostUsed: 'Meistgenutzt', + alpha: 'A–Z', + importedFirst: 'Importierte zuerst', + }, + filter: { + title: 'Anzeige filtern', + resources: 'Deine Ressourcen', + importedLibs: 'Importierte Bibliotheken', + exampleLibs: 'Beispiel-Bibliotheken (nicht installiert)', + builtin: 'BuiltIn', + hidingCategories: '{count} ausgeblendet', + clear: 'Alle anzeigen', + }, resizePanelHint: 'Ziehen, um das Detail-Panel zu verbreitern', startNodeLabel: 'Start', endNodeLabel: 'Ende', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1a114803..c08fa2eb 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -386,6 +386,10 @@ export default { title: 'Start New Run', repository: 'Project', selectRepo: 'Please select...', + tagsInclude: 'Include tags', + tagsExclude: 'Exclude tags', + tagsPlaceholder: 'smoke, regression', + tagsHint: 'Comma-separated. Runs only tests with these tags (robot --include / --exclude).', targetPath: 'Target Path', targetPlaceholder: 'tests/ or tests/login.robot', timeout: 'Timeout (seconds)', @@ -453,6 +457,7 @@ export default { shippedWithRoboscopeTitle: 'A version of this library ships bundled with RoboScope and is auto-installed into every new project venv. Clicking "Install" uses the bundled copy. Pin an explicit version to install from PyPI instead (once published).', conflictInstalled: 'Other variant installed', noPackages: 'No packages installed.', + managedByAdmin: 'Package management is managed by your administrator. Packages are shown read-only.', showAllPip: 'Show all installed packages ({count})', variables: 'Variables', noVariables: 'No variables defined.', @@ -621,6 +626,7 @@ export default { analysis: { title: 'AI Failure Analysis', analyzeButton: 'Analyze Failures', + verbosity: { label: 'Detail level', concise: 'Concise', standard: 'Standard', detailed: 'Detailed' }, noProvider: 'Configure an AI provider in Settings to enable failure analysis.', analyzing: 'Analyzing test failures... This may take 10-30 seconds.', failed: 'Analysis failed', @@ -817,6 +823,7 @@ export default { }, settings: { title: 'Settings', + lockedByEnv: 'Set via environment variable — managed by the server administrator.', general: 'General', users: 'Users', userManagement: 'User Management', @@ -1124,6 +1131,7 @@ export default { providerNamePlaceholder: 'e.g. My OpenAI, Local Llama', leaveBlankKeep: 'leave blank to keep current', ollamaHint: 'Ollama runs locally — no API key required. Make sure Ollama is running on your machine (default: http://localhost:11434). Use any model you have pulled, e.g. "ollama pull mistral".', + litellmHint: 'LiteLLM is an OpenAI-compatible gateway. Set the Base URL to your gateway endpoint (e.g. http://litellm.internal:4000) and enter the model name your gateway exposes. The API key is your gateway key (leave blank if the gateway needs none).', ollamaNoKey: 'not needed for Ollama', ollamaModelHint: 'Enter your model name, e.g. mistral:latest', confirmDeleteProvider: 'Delete provider "{name}"?', @@ -1270,6 +1278,30 @@ export default { examplesCategoryHint: 'Curated subset shown because the dynamic library introspection returned no data — typically the repo has no environment configured, or the library is not installed in the environment. Configure an environment to see the full keyword list.', currentFileCategoryBadge: 'this file', currentFileCategoryHint: 'Keywords defined in the file you are currently editing.', + resourcesSectionLabel: 'Your resources', + resourcesSectionHint: 'Keywords from .robot / .resource files in this repository. Inserting one auto-adds the matching Resource import.', + resourcePanelLabel: 'Resources', + librariesPanelLabel: 'Libraries', + importAdded: { + resourceTitle: 'Resource imported', + libraryTitle: 'Library imported', + message: 'Added {name} to *** Settings *** so the keyword resolves at runtime.', + }, + sort: { + title: 'Sort keywords', + mostUsed: 'Most used', + alpha: 'A–Z', + importedFirst: 'Imported first', + }, + filter: { + title: "Filter what's shown", + resources: 'Your resources', + importedLibs: 'Imported libraries', + exampleLibs: 'Example libraries (not installed)', + builtin: 'BuiltIn', + hidingCategories: '{count} hidden', + clear: 'Show all', + }, resizePanelHint: 'Drag to resize the detail panel', startNodeLabel: 'Start', endNodeLabel: 'End', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 50e66dc5..9f5b61ac 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -386,6 +386,10 @@ export default { title: 'Iniciar nuevo run', repository: 'Proyecto', selectRepo: 'Por favor seleccione...', + tagsInclude: 'Incluir etiquetas', + tagsExclude: 'Excluir etiquetas', + tagsPlaceholder: 'smoke, regression', + tagsHint: 'Separadas por comas. Ejecuta solo las pruebas con estas etiquetas (robot --include / --exclude).', targetPath: 'Ruta destino', targetPlaceholder: 'tests/ o tests/login.robot', timeout: 'Timeout (segundos)', @@ -453,6 +457,7 @@ export default { shippedWithRoboscopeTitle: 'Una versión de esta biblioteca se entrega junto con RoboScope y se instala automáticamente en cada nuevo entorno virtual de proyecto. Al hacer clic en «Instalar» se usa la copia incluida. Especifique una versión explícita para instalar desde PyPI (cuando esté publicado).', conflictInstalled: 'Otra variante instalada', noPackages: 'No hay paquetes instalados.', + managedByAdmin: 'La gestión de paquetes está administrada por su administrador. Los paquetes se muestran en modo de solo lectura.', showAllPip: 'Mostrar todos los paquetes instalados ({count})', variables: 'Variables', noVariables: 'No hay variables definidas.', @@ -621,6 +626,7 @@ export default { analysis: { title: 'Análisis IA de fallos', analyzeButton: 'Analizar fallos', + verbosity: { label: 'Nivel de detalle', concise: 'Conciso', standard: 'Estándar', detailed: 'Detallado' }, noProvider: 'Configure un proveedor de IA en Configuración para habilitar el análisis de fallos.', analyzing: 'Analizando fallos de tests... Esto puede tardar entre 10 y 30 segundos.', failed: 'El análisis ha fallado', @@ -817,6 +823,7 @@ export default { }, settings: { title: 'Configuración', + lockedByEnv: 'Definido mediante variable de entorno — gestionado por el administrador del servidor.', general: 'General', users: 'Usuarios', userManagement: 'Gestión de usuarios', @@ -1124,6 +1131,7 @@ export default { providerNamePlaceholder: 'ej. Mi OpenAI, Llama local', leaveBlankKeep: 'dejar vacío para mantener el actual', ollamaHint: 'Ollama se ejecuta localmente — no se necesita clave API. Asegúrate de que Ollama esté en ejecución en tu máquina (predeterminado: http://localhost:11434). Usa cualquier modelo que hayas descargado, ej. "ollama pull mistral".', + litellmHint: 'LiteLLM es una pasarela compatible con OpenAI. Define como Base URL el endpoint de tu pasarela (p. ej. http://litellm.internal:4000) e introduce el nombre del modelo que expone tu pasarela. La clave API es la de tu pasarela (déjala vacía si no requiere ninguna).', ollamaNoKey: 'no necesario para Ollama', ollamaModelHint: 'Introduce el nombre del modelo, ej. mistral:latest', confirmDeleteProvider: '¿Eliminar el proveedor "{name}"?', @@ -1270,6 +1278,30 @@ export default { examplesCategoryHint: 'Subconjunto curado mostrado porque la introspección dinámica de bibliotecas no devolvió datos — normalmente el repositorio no tiene un entorno configurado, o la biblioteca no está instalada en el entorno. Configura un entorno para ver la lista completa de palabras clave.', currentFileCategoryBadge: 'archivo actual', currentFileCategoryHint: 'Palabras clave definidas en el archivo que estás editando.', + resourcesSectionLabel: 'Tus recursos', + resourcesSectionHint: 'Palabras clave de archivos .robot / .resource de este repositorio. Insertar una añade automáticamente el import Resource correspondiente.', + resourcePanelLabel: 'Recursos', + librariesPanelLabel: 'Bibliotecas', + importAdded: { + resourceTitle: 'Recurso importado', + libraryTitle: 'Biblioteca importada', + message: 'Se añadió {name} a *** Settings *** para que la palabra clave se resuelva en tiempo de ejecución.', + }, + sort: { + title: 'Ordenar palabras clave', + mostUsed: 'Más usadas', + alpha: 'A–Z', + importedFirst: 'Importadas primero', + }, + filter: { + title: 'Filtrar lo que se muestra', + resources: 'Tus recursos', + importedLibs: 'Bibliotecas importadas', + exampleLibs: 'Bibliotecas de ejemplo (no instaladas)', + builtin: 'BuiltIn', + hidingCategories: '{count} ocultas', + clear: 'Mostrar todo', + }, resizePanelHint: 'Arrastra para redimensionar el panel de detalles', startNodeLabel: 'Inicio', endNodeLabel: 'Fin', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index b3e48335..8123a96d 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -386,6 +386,10 @@ export default { title: 'Démarrer un nouveau run', repository: 'Projet', selectRepo: 'Veuillez sélectionner...', + tagsInclude: 'Inclure les tags', + tagsExclude: 'Exclure les tags', + tagsPlaceholder: 'smoke, regression', + tagsHint: 'Séparés par des virgules. N\'exécute que les tests portant ces tags (robot --include / --exclude).', targetPath: 'Chemin cible', targetPlaceholder: 'tests/ ou tests/login.robot', timeout: 'Timeout (secondes)', @@ -453,6 +457,7 @@ export default { shippedWithRoboscopeTitle: 'Une version de cette bibliothèque est livrée avec RoboScope et installée automatiquement dans chaque nouvel environnement virtuel de projet. Cliquer sur « Installer » utilise la copie fournie. Spécifiez une version explicite pour installer depuis PyPI (une fois publié).', conflictInstalled: 'Autre variante installée', noPackages: 'Aucun paquet installé.', + managedByAdmin: 'La gestion des paquets est administrée par votre administrateur. Les paquets sont affichés en lecture seule.', showAllPip: 'Afficher tous les paquets installés ({count})', variables: 'Variables', noVariables: 'Aucune variable définie.', @@ -621,6 +626,7 @@ export default { analysis: { title: 'Analyse IA des erreurs', analyzeButton: 'Analyser les erreurs', + verbosity: { label: 'Niveau de détail', concise: 'Concis', standard: 'Standard', detailed: 'Détaillé' }, noProvider: "Configurez un fournisseur IA dans les Paramètres pour activer l'analyse des erreurs.", analyzing: "Analyse des erreurs de test en cours... Cela peut prendre 10 à 30 secondes.", failed: "L'analyse a échoué", @@ -817,6 +823,7 @@ export default { }, settings: { title: 'Paramètres', + lockedByEnv: 'Défini via une variable d’environnement — géré par l’administrateur du serveur.', general: 'Général', users: 'Utilisateurs', userManagement: 'Gestion des utilisateurs', @@ -1124,6 +1131,7 @@ export default { providerNamePlaceholder: 'ex. Mon OpenAI, Llama local', leaveBlankKeep: 'laisser vide pour conserver l\'actuel', ollamaHint: 'Ollama s\'exécute localement — aucune clé API requise. Assurez-vous qu\'Ollama est en cours d\'exécution sur votre machine (par défaut : http://localhost:11434). Utilisez n\'importe quel modèle que vous avez téléchargé, par ex. "ollama pull mistral".', + litellmHint: 'LiteLLM est une passerelle compatible OpenAI. Indiquez comme Base URL le point d\'accès de votre passerelle (p. ex. http://litellm.internal:4000) et saisissez le nom du modèle exposé par votre passerelle. La clé API est celle de votre passerelle (laissez vide si elle n\'en exige pas).', ollamaNoKey: 'pas nécessaire pour Ollama', ollamaModelHint: 'Entrez le nom du modèle, ex. mistral:latest', confirmDeleteProvider: 'Supprimer le fournisseur « {name} » ?', @@ -1270,6 +1278,30 @@ export default { examplesCategoryHint: 'Sous-ensemble choisi affiché parce que l\'introspection dynamique des bibliothèques n\'a renvoyé aucune donnée — typiquement le dépôt n\'a pas d\'environnement configuré, ou la bibliothèque n\'est pas installée dans l\'environnement. Configurez un environnement pour voir la liste complète des mots-clés.', currentFileCategoryBadge: 'fichier actuel', currentFileCategoryHint: 'Mots-clés définis dans le fichier que vous éditez actuellement.', + resourcesSectionLabel: 'Vos ressources', + resourcesSectionHint: 'Mots-clés des fichiers .robot / .resource de ce dépôt. En insérer un ajoute automatiquement l\'import Resource correspondant.', + resourcePanelLabel: 'Ressources', + librariesPanelLabel: 'Bibliothèques', + importAdded: { + resourceTitle: 'Ressource importée', + libraryTitle: 'Bibliothèque importée', + message: '{name} a été ajouté à *** Settings *** afin que le mot-clé soit résolu à l\'exécution.', + }, + sort: { + title: 'Trier les mots-clés', + mostUsed: 'Les plus utilisés', + alpha: 'A–Z', + importedFirst: 'Importées d\'abord', + }, + filter: { + title: 'Filtrer l\'affichage', + resources: 'Vos ressources', + importedLibs: 'Bibliothèques importées', + exampleLibs: 'Bibliothèques d\'exemple (non installées)', + builtin: 'BuiltIn', + hidingCategories: '{count} masquées', + clear: 'Tout afficher', + }, resizePanelHint: 'Glissez pour redimensionner le panneau de détails', startNodeLabel: 'Début', endNodeLabel: 'Fin', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 86a2e0ec..0cf3432b 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -418,6 +418,10 @@ const overrides: Dict = { title: '开始新运行', repository: '项目', selectRepo: '请选择...', + tagsInclude: '包含标签', + tagsExclude: '排除标签', + tagsPlaceholder: 'smoke, regression', + tagsHint: '以逗号分隔。仅运行带这些标签的测试(robot --include / --exclude)。', targetPath: '目标路径', targetPlaceholder: 'tests/ 或 tests/login.robot', timeout: '超时(秒)', @@ -485,6 +489,7 @@ const overrides: Dict = { shippedWithRoboscopeTitle: '此库的某个版本与 RoboScope 捆绑提供,并会自动安装到每个新项目的虚拟环境中。点击"安装"将使用捆绑的副本。固定一个明确的版本可改为从 PyPI 安装(一旦发布)。', conflictInstalled: '已安装其他变体', noPackages: '未安装任何包。', + managedByAdmin: '包管理由您的管理员统一管理。包以只读方式显示。', showAllPip: '显示所有已安装的包({count})', variables: '变量', noVariables: '未定义任何变量。', @@ -653,6 +658,7 @@ const overrides: Dict = { analysis: { title: 'AI 失败分析', analyzeButton: '分析失败', + verbosity: { label: '详细程度', concise: '简洁', standard: '标准', detailed: '详细' }, noProvider: '请在设置中配置 AI 提供方以启用失败分析。', analyzing: '正在分析测试失败… 此过程可能需要 10-30 秒。', failed: '分析失败', @@ -849,6 +855,7 @@ const overrides: Dict = { }, settings: { title: '设置', + lockedByEnv: '通过环境变量设置 — 由服务器管理员统一管理。', general: '通用', users: '用户', userManagement: '用户管理', @@ -1111,6 +1118,7 @@ const overrides: Dict = { providerNamePlaceholder: '例如 My OpenAI、Local Llama', leaveBlankKeep: '留空以保留当前值', ollamaHint: 'Ollama 在本地运行 — 无需 API 密钥。请确保 Ollama 正在你的机器上运行(默认:http://localhost:11434)。使用任何你已拉取的模型,例如 "ollama pull mistral"。', + litellmHint: 'LiteLLM 是兼容 OpenAI 的网关。将 Base URL 设为你的网关地址(例如 http://litellm.internal:4000),并填写网关提供的模型名称。API 密钥为你的网关密钥(若网关无需密钥则留空)。', ollamaNoKey: 'Ollama 不需要', ollamaModelHint: '输入你的模型名称,例如 mistral:latest', confirmDeleteProvider: '删除提供商“{name}”?', diff --git a/frontend/src/stores/ai.store.ts b/frontend/src/stores/ai.store.ts index cb89ff1e..4cda3e09 100644 --- a/frontend/src/stores/ai.store.ts +++ b/frontend/src/stores/ai.store.ts @@ -108,13 +108,19 @@ export const useAiStore = defineStore('ai', () => { // --- Analysis --- - async function analyzeFailures(reportId: number, providerId?: number, language?: string) { + async function analyzeFailures( + reportId: number, + providerId?: number, + language?: string, + verbosity?: string, + ) { loading.value = true try { const job = await aiApi.analyzeFailures({ report_id: reportId, provider_id: providerId, language, + verbosity, }) analysisJob.value = job startAnalysisPolling(job.id) diff --git a/frontend/src/stores/auth.store.ts b/frontend/src/stores/auth.store.ts index b7a4e109..db8cbfd7 100644 --- a/frontend/src/stores/auth.store.ts +++ b/frontend/src/stores/auth.store.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import * as authApi from '@/api/auth.api' +import { useFeatureFlags } from '@/composables/useFeatureFlags' import type { User } from '@/types/domain.types' export const useAuthStore = defineStore('auth', () => { @@ -22,6 +23,8 @@ export const useAuthStore = defineStore('auth', () => { localStorage.setItem('access_token', response.access_token) localStorage.setItem('refresh_token', response.refresh_token) await fetchCurrentUser() + // Refetch deployment feature flags for the freshly logged-in user. + void useFeatureFlags().refresh() } async function fetchCurrentUser() { @@ -38,6 +41,7 @@ export const useAuthStore = defineStore('auth', () => { token.value = null localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') + useFeatureFlags().reset() } async function markFirstLoginComplete() { diff --git a/frontend/src/tests/components/FlowEditorPrefillArgs.spec.ts b/frontend/src/tests/components/FlowEditorPrefillArgs.spec.ts new file mode 100644 index 00000000..ec8fb09c --- /dev/null +++ b/frontend/src/tests/components/FlowEditorPrefillArgs.spec.ts @@ -0,0 +1,91 @@ +/** + * UX D3 (ux-flow-editor-resources.md) — inserting a keyword pre-seeds one + * EMPTY argument slot per REQUIRED positional argument from its signature, + * so the detail panel opens with the parameters the user must fill already + * present (fixes F3: a resource keyword `[Arguments] ${url}` showed a bare + * "+ add argument"). Optionals/varargs/kwargs stay behind the picker. + * + * Mounting FlowEditor (Vue Flow) is heavy, so this mirrors the real + * `prefillRequiredArgs` logic standalone — the same shape pinned by the + * other FlowEditor helper specs. + */ +import { describe, it, expect } from 'vitest' +import { parseArgSignature, type ParsedArg } from '@/utils/robotKeywordSignatures' + +interface StepLike { + type: string + keyword: string + args: string[] +} + +/** Mirror of FlowEditor.vue::prefillRequiredArgs. */ +function prefillRequiredArgs(step: StepLike, specsFor: (kw: string) => ParsedArg[] | null): void { + if (step.type !== 'keyword' && step.type !== 'assignment') return + if (step.args.length > 0) return + const specs = specsFor(step.keyword) + if (!specs) return + for (const s of specs) { + if (s.kind === 'positional') step.args.push('') + else break + } +} + +const SIGS: Record = { + // resource keyword — one required positional + 'open login page': ['${url}'].map(parseArgSignature), + // library keyword — required selector then optionals + click: ['selector', 'button=left', 'clickCount=1'].map(parseArgSignature), + // two required positionals then a vararg + 'log many': ['first', 'second', '*rest'].map(parseArgSignature), + // no required positionals (all optional, real defaults) + log: ['level=INFO', 'html=False'].map(parseArgSignature), +} +const specsFor = (kw: string) => SIGS[kw.toLowerCase()] ?? null + +function makeStep(keyword: string, type = 'keyword'): StepLike { + return { type, keyword, args: [] } +} + +describe('prefillRequiredArgs (D3)', () => { + it('seeds one empty slot for a single required positional (the F3 case)', () => { + const s = makeStep('Open Login Page') + prefillRequiredArgs(s, specsFor) + expect(s.args).toEqual(['']) + }) + + it('seeds only the required positionals, stopping at the first optional', () => { + const s = makeStep('Click') + prefillRequiredArgs(s, specsFor) + expect(s.args).toEqual(['']) // selector only; button/clickCount stay behind the picker + }) + + it('seeds multiple required positionals, stopping at a vararg', () => { + const s = makeStep('Log Many') + prefillRequiredArgs(s, specsFor) + expect(s.args).toEqual(['', '']) + }) + + it('seeds nothing for an all-optional signature', () => { + const s = makeStep('Log') + prefillRequiredArgs(s, specsFor) + expect(s.args).toEqual([]) + }) + + it('seeds nothing for an unknown keyword (no signature)', () => { + const s = makeStep('Totally Custom Keyword') + prefillRequiredArgs(s, specsFor) + expect(s.args).toEqual([]) + }) + + it('never clobbers caller-provided args', () => { + const s: StepLike = { type: 'keyword', keyword: 'Click', args: ['css=.btn'] } + prefillRequiredArgs(s, specsFor) + expect(s.args).toEqual(['css=.btn']) + }) + + it('ignores non-keyword step types (control structures)', () => { + const s = makeStep('${x}', 'if') + prefillRequiredArgs(s, specsFor) + expect(s.args).toEqual([]) + }) +}) diff --git a/frontend/src/tests/components/PaletteView.spec.ts b/frontend/src/tests/components/PaletteView.spec.ts new file mode 100644 index 00000000..f4fcb476 --- /dev/null +++ b/frontend/src/tests/components/PaletteView.spec.ts @@ -0,0 +1,124 @@ +/** + * UX D5 / D6 (ux-flow-editor-resources.md) — pure view-state helpers for the + * Flow Editor keyword palette: the adaptive "what's shown" filter default, the + * sophisticated-file heuristic, filtering, hidden-count, and library sort. + */ +import { describe, it, expect } from 'vitest' +import { + adaptiveDefaultFilter, + isSophisticatedFile, + applyFilter, + hiddenCount, + sortLibraries, + bucketOf, + parseStoredFilter, + parseStoredSort, + SOPHISTICATED_MIN_STEPS, + type CatLike, + type PaletteFilter, +} from '@/components/editor/flow/paletteView' + +const RESOURCE: CatLike = { name: 'common.resource', kind: 'resource' } +const BUILTIN: CatLike = { name: 'BuiltIn', kind: 'library' } +const REAL_LIB: CatLike = { name: 'Browser', kind: 'library', imported: true } +const EXAMPLE_LIB: CatLike = { name: 'SeleniumLibrary', kind: 'library', isExamples: true } +const CONTROL: CatLike = { name: 'Control', kind: 'control' } + +describe('isSophisticatedFile (D6 heuristic)', () => { + it('is sophisticated when the file already imports something', () => { + expect(isSophisticatedFile({ importCount: 1, stepCount: 0 })).toBe(true) + }) + it('is sophisticated at the step threshold', () => { + expect(isSophisticatedFile({ importCount: 0, stepCount: SOPHISTICATED_MIN_STEPS })).toBe(true) + }) + it('is a mini/fresh file with no imports and few steps', () => { + expect(isSophisticatedFile({ importCount: 0, stepCount: 2 })).toBe(false) + }) +}) + +describe('adaptiveDefaultFilter (D6 decided 2026-06-18)', () => { + it('hides example libs for a sophisticated file in an env-backed repo', () => { + const f = adaptiveDefaultFilter({ hasEnvData: true, file: { importCount: 3, stepCount: 12 } }) + expect(f).toEqual({ resources: true, importedLibs: true, exampleLibs: false, builtin: true }) + }) + it('shows everything for an env-less repo (pure discovery)', () => { + const f = adaptiveDefaultFilter({ hasEnvData: false, file: { importCount: 3, stepCount: 12 } }) + expect(f.exampleLibs).toBe(true) + }) + it('shows everything for a fresh/mini file even with an env', () => { + const f = adaptiveDefaultFilter({ hasEnvData: true, file: { importCount: 0, stepCount: 1 } }) + expect(f.exampleLibs).toBe(true) + }) +}) + +describe('bucketOf', () => { + it('maps categories to their filter buckets, Control to none', () => { + expect(bucketOf(RESOURCE)).toBe('resources') + expect(bucketOf(BUILTIN)).toBe('builtin') + expect(bucketOf(REAL_LIB)).toBe('importedLibs') + expect(bucketOf(EXAMPLE_LIB)).toBe('exampleLibs') + expect(bucketOf(CONTROL)).toBeNull() + }) +}) + +describe('applyFilter + hiddenCount (D6)', () => { + const cats = [RESOURCE, BUILTIN, REAL_LIB, EXAMPLE_LIB, CONTROL] + it('keeps Control regardless of the filter', () => { + const none: PaletteFilter = { resources: false, importedLibs: false, exampleLibs: false, builtin: false } + expect(applyFilter(cats, none)).toEqual([CONTROL]) + }) + it('hides only the example libs under the imported-only default', () => { + const f: PaletteFilter = { resources: true, importedLibs: true, exampleLibs: false, builtin: true } + const out = applyFilter(cats, f) + expect(out).not.toContain(EXAMPLE_LIB) + expect(out).toContain(REAL_LIB) + expect(hiddenCount(cats, f)).toBe(1) + }) + it('reports zero hidden when everything is shown', () => { + const all: PaletteFilter = { resources: true, importedLibs: true, exampleLibs: true, builtin: true } + expect(hiddenCount(cats, all)).toBe(0) + }) +}) + +describe('sortLibraries (D5)', () => { + const libs: CatLike[] = [ + { name: 'Browser', kind: 'library', isExamples: false }, + { name: 'AppiumLibrary', kind: 'library', isExamples: true }, + { name: 'Collections', kind: 'library', isExamples: false }, + ] + const usage = new Map([['Collections', 5], ['Browser', 2]]) + + it('mostUsed orders by usage desc', () => { + expect(sortLibraries(libs, 'mostUsed', usage).map((l) => l.name)).toEqual([ + 'Collections', 'Browser', 'AppiumLibrary', + ]) + }) + it('alpha orders case-insensitively A–Z', () => { + expect(sortLibraries(libs, 'alpha', usage).map((l) => l.name)).toEqual([ + 'AppiumLibrary', 'Browser', 'Collections', + ]) + }) + it('importedFirst puts non-example libs ahead of examples', () => { + const out = sortLibraries(libs, 'importedFirst', usage).map((l) => l.name) + expect(out.indexOf('AppiumLibrary')).toBe(out.length - 1) // the only example → last + expect(out.slice(0, 2).sort()).toEqual(['Browser', 'Collections']) + }) +}) + +describe('parseStoredFilter / parseStoredSort (persistence)', () => { + it('round-trips a valid filter', () => { + const f: PaletteFilter = { resources: true, importedLibs: false, exampleLibs: true, builtin: false } + expect(parseStoredFilter(JSON.stringify(f))).toEqual(f) + }) + it('rejects malformed / partial / null filter blobs', () => { + expect(parseStoredFilter(null)).toBeNull() + expect(parseStoredFilter('not json')).toBeNull() + expect(parseStoredFilter('{"resources":true}')).toBeNull() + }) + it('accepts only known sort modes', () => { + expect(parseStoredSort('alpha')).toBe('alpha') + expect(parseStoredSort('importedFirst')).toBe('importedFirst') + expect(parseStoredSort('bogus')).toBeNull() + expect(parseStoredSort(null)).toBeNull() + }) +}) diff --git a/frontend/src/tests/components/resourcePath.spec.ts b/frontend/src/tests/components/resourcePath.spec.ts new file mode 100644 index 00000000..44a61ffe --- /dev/null +++ b/frontend/src/tests/components/resourcePath.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { resourceImportPath } from '@/components/editor/flow/resourcePath' + +describe('resourceImportPath', () => { + it('goes up to a sibling directory', () => { + expect(resourceImportPath('tests/login.robot', 'resources/common.resource')) + .toBe('../resources/common.resource') + }) + + it('same directory → bare basename', () => { + expect(resourceImportPath('tests/login.robot', 'tests/common.resource')) + .toBe('common.resource') + }) + + it('both at repo root', () => { + expect(resourceImportPath('login.robot', 'common.resource')).toBe('common.resource') + }) + + it('nested open file, divergent resource path', () => { + expect(resourceImportPath('a/b/test.robot', 'a/c/d/foo.resource')) + .toBe('../c/d/foo.resource') + }) + + it('resource deeper under the open file directory', () => { + expect(resourceImportPath('tests/suite.robot', 'tests/shared/kw.resource')) + .toBe('shared/kw.resource') + }) + + it('no open-file context → resource path as-is', () => { + expect(resourceImportPath('', 'resources/common.resource')) + .toBe('resources/common.resource') + }) + + it('tolerates Windows-style separators and ./ prefix', () => { + expect(resourceImportPath('tests\\login.robot', './resources/common.resource')) + .toBe('../resources/common.resource') + }) +}) diff --git a/frontend/src/tests/composables/useFeatureFlags.spec.ts b/frontend/src/tests/composables/useFeatureFlags.spec.ts new file mode 100644 index 00000000..5998cd98 --- /dev/null +++ b/frontend/src/tests/composables/useFeatureFlags.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { getFeatures } = vi.hoisted(() => ({ getFeatures: vi.fn() })) +vi.mock('@/api/governance.api', () => ({ getFeatures })) + +import { useFeatureFlags } from '@/composables/useFeatureFlags' + +// localStorage is a vi.fn() mock in src/tests/setup.ts — drive getItem directly. +function setToken(token: string | null) { + vi.mocked(localStorage.getItem).mockReturnValue(token) +} + +describe('useFeatureFlags', () => { + beforeEach(() => { + getFeatures.mockReset() + setToken(null) + }) + + it('does NOT fetch when there is no access token (redirect-loop guard)', async () => { + setToken(null) + const ff = useFeatureFlags() + await ff.refresh() + expect(getFeatures).not.toHaveBeenCalled() + }) + + it('reflects disabled + locked flags after refresh', async () => { + setToken('x') + getFeatures.mockResolvedValue({ + flags: { packageManagement: false }, + locked: { packageManagement: true }, + }) + const ff = useFeatureFlags() + await ff.refresh() + expect(getFeatures).toHaveBeenCalled() + expect(ff.isEnabled('packageManagement')).toBe(false) + expect(ff.isLocked('packageManagement')).toBe(true) + }) + + it('defaults to enabled for an unknown flag', async () => { + setToken('x') + getFeatures.mockResolvedValue({ flags: { packageManagement: true }, locked: {} }) + const ff = useFeatureFlags() + await ff.refresh() + expect(ff.isEnabled('someUnknownFlag')).toBe(true) + }) + + it('degrades to enabled when the fetch fails (server still enforces)', async () => { + setToken('x') + getFeatures.mockRejectedValue(new Error('500')) + const ff = useFeatureFlags() + await ff.refresh() + expect(ff.isEnabled('aFreshFlagName')).toBe(true) + }) +}) diff --git a/frontend/src/tests/i18n/language-and-docs-consistency.spec.ts b/frontend/src/tests/i18n/language-and-docs-consistency.spec.ts index aea57ef1..c4b3b476 100644 --- a/frontend/src/tests/i18n/language-and-docs-consistency.spec.ts +++ b/frontend/src/tests/i18n/language-and-docs-consistency.spec.ts @@ -62,4 +62,20 @@ describe('release gate — language & docs consistency', () => { expect(sectionIds(d), `'${code}' docs top-level sections drifted from en`).toEqual(reference) } }) + + it('en/fr/es share identical subsection ids per section', () => { + // EN/FR/ES are authored in lockstep, so their subsection trees must match + // exactly — this catches a subsection added to one but not the others. + // German (de) is intentionally NOT included: its docs use an independent + // subsection structure + id scheme (different granularity, e.g. a combined + // sync section, a DE-only API page), so it is pinned at top-level only + // (the test above). Enforcing de subsection parity would force artificial + // content changes. + const subTree = (d: typeof enDocs) => + Object.fromEntries(d.map((s) => [s.id, s.subsections.map((ss) => ss.id).sort()])) + const ref = subTree(enDocs) + for (const [code, d] of Object.entries({ fr: frDocs, es: esDocs })) { + expect(subTree(d), `'${code}' docs subsections drifted from en`).toEqual(ref) + } + }) }) diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts index 351b2559..32da797b 100644 --- a/frontend/src/types/api.types.ts +++ b/frontend/src/types/api.types.ts @@ -72,7 +72,7 @@ export interface SettingUpdateRequest { export interface AiProviderCreateRequest { name: string - provider_type: 'openai' | 'anthropic' | 'openrouter' | 'ollama' + provider_type: 'openai' | 'anthropic' | 'openrouter' | 'ollama' | 'litellm' api_base_url?: string | null api_key?: string | null model_name: string @@ -112,6 +112,8 @@ export interface AiAnalyzeRequest { /** Frontend i18n locale (de/en/fr/es/zh) so the analysis prose comes back * in the user's current UI language. */ language?: string | null + /** Analysis length: concise | standard | detailed. */ + verbosity?: string | null } export interface AiValidateSpecRequest { diff --git a/frontend/src/types/domain.types.ts b/frontend/src/types/domain.types.ts index ada194c8..39f34eee 100644 --- a/frontend/src/types/domain.types.ts +++ b/frontend/src/types/domain.types.ts @@ -467,7 +467,7 @@ export interface LibraryCheckResponse { // --- AI Generation types --- -export type AiProviderType = 'openai' | 'anthropic' | 'openrouter' | 'ollama' +export type AiProviderType = 'openai' | 'anthropic' | 'openrouter' | 'ollama' | 'litellm' export type AiJobStatus = 'pending' | 'running' | 'completed' | 'failed' export type AiJobType = 'generate' | 'reverse' | 'analyze' diff --git a/frontend/src/views/EnvironmentsView.vue b/frontend/src/views/EnvironmentsView.vue index 4cddb169..ceedd0d3 100644 --- a/frontend/src/views/EnvironmentsView.vue +++ b/frontend/src/views/EnvironmentsView.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useEnvironmentsStore } from '@/stores/environments.store' import { useToast } from '@/composables/useToast' +import { useFeatureFlags } from '@/composables/useFeatureFlags' import { extractErrorDetail, extractErrorStatus } from '@/utils/errors' import * as envsApi from '@/api/environments.api' import type { EnvironmentPackage } from '@/types/domain.types' @@ -15,6 +16,12 @@ const envs = useEnvironmentsStore() const toast = useToast() const { t } = useI18n() +// Epic GOV — when package management is disabled for this deployment, all +// mutating controls are hidden and a read-only notice is shown. The server +// also enforces (403); this is the matching UX. +const { isEnabled } = useFeatureFlags() +const pkgMgmt = computed(() => isEnabled('packageManagement')) + const showAddDialog = ref(false) const defaultEnvName = () => { const existing = envs.environments.map(e => e.name) @@ -352,7 +359,10 @@ function isBrowserConflict(pkg: { name: string; group?: string }): boolean {

{{ t('environments.packages') }}

- {{ t('environments.installPkg') }} + {{ t('environments.installPkg') }} +
+
+ 🔒 {{ t('environments.managedByAdmin') }}
@@ -376,7 +386,7 @@ function isBrowserConflict(pkg: { name: string; group?: string }): boolean { ⚠️ {{ t('environments.rfbrowserInitNeeded') }}
-
+
{{ t('environments.rfbrowserInit') }} @@ -490,7 +500,7 @@ function isBrowserConflict(pkg: { name: string; group?: string }): boolean {
-
+
{{ t('execution.runDialog.timeout') }}
+
+ + + {{ t('execution.runDialog.tagsHint') }} +
+
+ + +
diff --git a/frontend/src/views/ReportDetailView.vue b/frontend/src/views/ReportDetailView.vue index 0e0293fe..bf2bf281 100644 --- a/frontend/src/views/ReportDetailView.vue +++ b/frontend/src/views/ReportDetailView.vue @@ -142,6 +142,7 @@ const allInstalled = computed(() => // --- AI Failure Analysis --- const analysisError = ref('') +const verbosity = ref<'concise' | 'standard' | 'detailed'>('standard') // Scope the shared store analysis to THIS report. The store holds a single // `analysisJob`; the guard stops an analysis generated for another report @@ -155,7 +156,7 @@ const analysis = computed(() => async function startAnalysis() { analysisError.value = '' try { - await aiStore.analyzeFailures(reportId.value, undefined, locale.value) + await aiStore.analyzeFailures(reportId.value, undefined, locale.value, verbosity.value) } catch (e: unknown) { analysisError.value = extractErrorDetail(e, 'Analysis failed') } @@ -391,6 +392,14 @@ async function startAnalysis() {
+ {{ t('reportDetail.analysis.analyzeButton') }} diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index d5d2d021..06b68041 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -6,6 +6,7 @@ import * as authApi from '@/api/auth.api' import * as auditApi from '@/api/audit.api' import type { AuditLogEntry, AuditFilters } from '@/api/audit.api' import { useToast } from '@/composables/useToast' +import { useFeatureFlags } from '@/composables/useFeatureFlags' import BaseButton from '@/components/ui/BaseButton.vue' import BaseSpinner from '@/components/ui/BaseSpinner.vue' import BaseBadge from '@/components/ui/BaseBadge.vue' @@ -21,6 +22,15 @@ import * as webhooksApi from '@/api/webhooks.api' const toast = useToast() const { t } = useI18n() + +// Epic GOV — a `features.` setting whose flag is locked by an ENV +// override is non-editable here (the backend ignores the DB value). Disable +// the input and show a hint so an admin isn't misled into "saving" a no-op. +const { isLocked } = useFeatureFlags() +function settingLocked(key: string): boolean { + const prefix = 'features.' + return key.startsWith(prefix) && isLocked(key.slice(prefix.length)) +} const aiStore = useAiStore() const envStore = useEnvironmentsStore() const reposStore = useReposStore() @@ -554,6 +564,7 @@ function formatSize(bytes: number): string { v-model="editedValues[setting.key]" class="form-select" style="width: 120px" + :disabled="settingLocked(setting.key)" > @@ -563,7 +574,11 @@ function formatSize(bytes: number): string { v-model="editedValues[setting.key]" class="form-input" style="width: 200px" + :disabled="settingLocked(setting.key)" /> + + 🔒 {{ t('settings.lockedByEnv') }} +