From db2f0fb62a36d463ef9fcd3c24847e46e55811d5 Mon Sep 17 00:00:00 2001 From: Loakesh Indiran <66478092+Loaki07@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:30:19 +0530 Subject: [PATCH] feat(synthetics): attempts view, evidence panel, alerting, concurrency (#13543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > Note: this branch carries ~20 earlier synthetics commits. The alerting piece is ~280 lines and is cleanly separable if a smaller review is wanted. ## Backend — alert state - **Four columns on `synthetics`** (`consecutive_failures`, `last_alert_at`, `alerting`, `degraded_notified_at`), all defaulted so no backfill is needed - **`DB_SCHEMA_VERSION` 55 → 56** — without the bump the migrator short-circuits and the columns never appear - Guarded with `has_column` on **both** `up` and `down`: SQLite ignores `IF NOT EXISTS` on `ADD COLUMN`, and sea-query has no `drop_column_if_exists` - **Compare-and-swap** accessor (`update_alert_state_if`), because the caller does a read-modify-write with no transaction - `alerting` is a separate column because without it "recovered" cannot be told from "was never alerting" - **`synthetics_jobs::failing_locations`** — the notification previously had no location field, so a six-location check with one broken region could only say "the check is failing" - Distinct subject, headline, icon and colour for FLAKY vs DEGRADED, from one shared `locations_line` helper so Slack/plain-text/email cannot drift > The table is `synthetics`, not `synthetics_monitors` — the entity module is named after the concept but declares `table_name = "synthetics"`. Recorded at the `DeriveIden` so the next column addition doesn't repeat it. ## Performance — the step-stats panel fetched ~35 MB per load - **`recorded_steps` out of the 5000-row tally** — ~60% of payload; ~4 KB/row and near-identical within a config version, so an *aggregation* re-read the same definitions 5000 times. Still written to every record, because steps are editable and history must not be rewritten - **`retry_history` out too**, replaced by `retry_step_ids` scanned only over rows that actually retried - **Key step stats by `step_id`, not name** — a rename *split* one step into two rows; two steps sharing a name *merged* into one - **Single-pass sparklines** — was O(steps²) per row: ~2M iterations for a 20-step journey, ~50M for a 100-step one - **Flaky Rate could exceed 100%** — the fallback incremented the numerator without the denominator - **Label the real window** — a 1-min check × 2 locations × 4 device combos is 11,520 executions/day, so a 5000-row cap is ~10 hours of a "last 7 days" selection ## UI - **Attempts selector** in the run detail — costs no request, `retry_history` is already on the row. Labels are 1-based while `attempt` is 0-based - Each attempt resolves **its own** screenshots, trace and evidence bundle; a superseded attempt's live under an `attempt-N-` key - **Evidence panel** — parses `evidence.ndjson` per line, grouped by kind in severity order, chips with counts visible at zero, third-party dimmed not dropped, truncation stated, four distinct empty states - **Flaky / Degraded / Unstable tiles.** Unstable is partitioned by `(location, device, engine)` — aggregated, a check solidly broken in one region and healthy in five is indistinguishable from one intermittently broken everywhere. It measures *oscillation*, not badness - All flakiness tiles read **"—" at `retries = 0`**, never `0.0%`: a zero is a measurement, that is the absence of one - **`init_ms` and queue delay shown separately** — init was observed at 113,131 ms on a cold Lambda against a 243 ms check; unsubtracted, Lambda locations look permanently slower at every percentile - Steps and Evidence are sibling `OTabPanel`s — stacking them broke the drawer's scroll ## Bugs fixed during review - **`mapRetryHistory` guarded on `Array.isArray`** but the search API returns blob columns as JSON *strings*, so it returned `[]` for every real record — the attempts strip could never render, whatever the query selected. Same bug in `mapEvidence` and the `attempts` fallback - **`mapRunDetail` read `rawHit.attempt`**; the field is `attempts`. The count was 0 on every run - **`mapRetryHistory` hard-coded `status: "failed"`** — reported a flaky run's *passing* final attempt as a failure - **A superseded attempt's failing step rendered as a pass** — the compact timeline's `passed`/`failed` was never normalised to the `ok`/`fail` vocabulary every consumer tests for - **`failure_detail` is not a column** — OpenObserve flattens nested objects; naming the object is rejected by the search API - **The retry-attribution query named `status_reason` unconditionally.** That field only exists once something has been a `warning`, and the three step queries share a `Promise.all` — so one missing column emptied the *entire* Steps tab - **`fetchSchemaFields` returned an empty Set on failure**, which the gate reads as "the schema has no fields", silently degrading every optional column to a literal ## Lease correctness (added after review of the concurrency docs) Two corruption-class bugs, both of which double-count the alert failure streak this PR introduces. Included here rather than in the concurrency branch because they undermine *this* PR's own feature. - **The browser lease validation was per-device while the work is per-job.** `validate_browser_config` computed `(retries+1) x journey_budget + retries x wait` against the 900s lease, but the probe runs `browser_devices` **sequentially inside** the leased job and nothing multiplied by the combo count (`MAX_BROWSER_DEVICE_COMBOS = 12`). At defaults a plain **desktop + mobile** config is 1210s of work; validation computed 605s and allowed it. It then blew its 900s lease on **every run, forever**, with nothing failing at save. At the cap: 7260s vs 900s. The consequence is verbatim what the `LEASE_SECS` comment in `dispatcher/mod.rs` was written to prevent — *"the reaper would requeue mid-run and the journey would EXECUTE AGAIN — duplicate result records, multiplied browser cost, and false alerts caused by the reliability fix itself."* All three happen at two devices today, on the **managed** path, since browser is Lambda-only. > ⚠️ **Breaking validation change.** An existing 2+ device check will fail to save until its budget, retries or combo count comes down. The error now names the combo count as a lever. **Query dev and prod for `json_array_length(config->'browser_devices') > 1` before rolling out.** - **`ack_complete` had no status guard** — `WHERE id = $4` alone. A duplicate ack succeeded and the caller called `increment_jobs_done` again for one job: `jobs_done` overshot `job_count`, the run was declared complete on a **partial set**, and since this PR it also advanced `consecutive_failures` twice, so `alert_if_fails: 3` fires after two failures. Reachable today with no concurrency work: `aws-sdk-lambda` retries a timed-out `RequestResponse` invoke, re-executing a check already in flight. `AND status = 1` makes it idempotent, and `ack_complete` now returns `None` when it did not apply so the caller skips run accounting. Reaper reassignment is covered too, as of the concurrency work merged below: `AND claimed_by = $5`. ## Verification `vue-tsc` + `type-check:app` clean · Prettier + design ratchet clean · **711 synthetics tests** · `cargo check` clean · infra unit tests pass. Migration applied against a real SQLite metastore: `55 → 56`, all columns correct. Every drawer field verified against live ingested data. > `MonitorResults.spec.ts` fails on a pre-existing `storage.ts` import error — identical on a stashed tree. ## Requires o2-enterprise#2305 — the `AlertDecision` this consumes. Merge together; each is inert alone. --- # Also merged: probe concurrency (#13552) The concurrency branch merged into this one, so this PR now carries both. Everything below is a correctness fix — none of it is throughput work. ### The reaper never completed a run, on any path A run completes when `jobs_done` reaches `job_count`, and only three callers move that counter: the probe ack, the dispatcher's failed invoke, and the reaper. The reaper never did — including in `dead_letter_expired`, whose whole purpose is to report "we gave up after N attempts". It wrote a customer-visible error record into a run that then stayed open forever: no `completed_at`, and since this PR's alerting work, no `resolve_alert` either, so the failure streak never moved. The mechanism was `requeue_expired` ignoring `valid_until`. It reset a job to Pending, and `prune_stale` — later in the **same tick** — deleted it again, because `valid_until` is one interval (60s for a 1-minute check) while a lease is 300s+. `dispatch_attempts` never got past 1, so `MAX_DISPATCH_ATTEMPTS` was unreachable and the retry budget was dead code on the agent path. - requeue only while the job is still inside its window - dead-letter everything that can no longer report, tagged with why (attempts exhausted / window closed / never dispatched), so a dead agent does not read as an unresponsive probe - per-row compare-and-swap instead of SELECT-then-bulk-UPDATE; the reaper runs on every alert_manager node - increment **before** the ingest-token lookup, which returns early when an org has no token ### Every check's retry sequence is bounded by the lease Retries run *inside* the leased job. The browser path has checked this since `journey_budget_ms`; the protocol path never did, and its inputs were unbounded — the `5_000..=300_000` bound on `timeout_ms` is **browser-only**, so a TCP check with `timeout_ms: 3_600_000` saved fine. With the reaper now completing runs, the symptom is a wrong answer rather than a stuck one: the lease expires mid-flight, the reaper completes the run as Error, and the probe's real ack is rejected as stale. A passing check reports Error permanently. `timeout_ms=100s, retries=2, wait=30` needs 360s and hits it. `lease_batch` treats the client's `lease_secs` as a floor request, not the decision — both probes guess 300s, and a probe cannot know how long its own job may take. ### Guards on the ack, and on dispatch failure `AND claimed_by = $5` closes reaper reassignment. `claimed_by` is `Option` during rollout: an older probe acks without it and falls back to the status guard, so nothing is discarded. `fail_dispatch` had no status guard, so a dispatch judged failed *after* the probe acked reset a **completed** job to Pending — leased again, run again, duplicate result, `increment_jobs_done` twice. Third outcome `AlreadySettled` added; the dispatcher matches all three with no wildcard, so handling it is compile-time enforced. `limit` is now clamped before the query. It is cast to `u64`, where a negative wraps to ~1.8e19 and the LIMIT goes unbounded — one agent leasing the entire pending queue for its pool. Reachable from one mistyped env var. ### Queue backlog is observable `synthetics_pending_jobs` and `synthetics_oldest_pending_age_seconds`, per location and pool. `started_ts - scheduled_ts` already covered work that *ran*; a check nobody leased produces no record, so its backlog was invisible by construction. ## Before merging this to main The `timeout_ms` validation is a **breaking change for existing protocol configs**. It runs at create/update only, so nothing stops on deploy — but an over-budget check is rejected the next time someone edits it. Expected result is zero rows: `retries` of 0 or 1 cannot trip the budget rule at all, and the defaults sit at 10,000ms, 1.1% of the lease. ```sql WITH c AS ( SELECT id, org_id, name, synthetics_type, COALESCE((config->>'timeout_ms')::bigint, 10000) AS t, COALESCE((settings->>'retries')::bigint, 0) AS r, COALESCE((settings->>'wait_before_retry_secs')::bigint, 5) AS w FROM synthetics WHERE synthetics_type <> 'browser' ) SELECT id, org_id, name, t AS timeout_ms, r AS retries, w AS wait_s, (r + 1) * t + r * w * 1000 AS worst_case_ms FROM c WHERE t NOT BETWEEN 1000 AND 300000 OR (r + 1) * t + r * w * 1000 > 900000 ORDER BY worst_case_ms DESC; ``` Anything returned is a check that **cannot work today either** — fix the config rather than loosening the rule. ## Not done Wiring the reaper's now-completing runs to a **notification**. The send lives in a crate that depends on `o2_enterprise`, so the reaper cannot call up into it; it needs the send moved into `openobserve_core::synthetics` or a queue. No regression — before this, the run never completed, so there was no notification either. --------- Signed-off-by: Yashodhan Joshi Co-authored-by: omkark06 Co-authored-by: Harsh Mahajan <115500013+007harshmahajan@users.noreply.github.com> Co-authored-by: Yashodhan Joshi Co-authored-by: Yashodhan Joshi Co-authored-by: sai nikhil kethe Co-authored-by: Shrinath Rao Co-authored-by: Claude Opus 4.8 --- .../management/src/request/synthetics/mod.rs | 20 +- src/config/src/config.rs | 2 +- src/config/src/meta/synthetics.rs | 1444 ++++++++++++++++- src/config/src/metrics.rs | 48 + src/core/src/synthetics.rs | 119 +- .../src/table/entity/synthetics_monitors.rs | 19 + ..._add_alert_state_to_synthetics_monitors.rs | 168 ++ src/infra/src/table/migration/mod.rs | 2 + src/infra/src/table/synthetics_jobs.rs | 507 +++++- src/infra/src/table/synthetics_monitors.rs | 92 ++ .../CreateBrowserTest.schema.spec.ts | 260 +++ .../synthetics/CreateBrowserTest.schema.ts | 72 +- .../synthetics/StepEvidence.spec.ts | 199 +++ .../components/synthetics/StepEvidence.vue | 248 +++ .../synthetics/journey/BrowserJourney.spec.ts | 328 +++- .../synthetics/journey/BrowserJourney.vue | 348 ++-- .../journey/BrowserJourneyAssertion.spec.ts | 92 ++ .../journey/BrowserJourneyAssertion.vue | 104 ++ .../journey/BrowserJourneyLocator.spec.ts | 289 ++++ .../journey/BrowserJourneyLocator.vue | 307 ++++ .../journey/BrowserJourneyStep.spec.ts | 270 ++- .../synthetics/journey/BrowserJourneyStep.vue | 146 +- .../journey/BrowserJourneyStepEditor.spec.ts | 392 +++++ .../journey/BrowserJourneyStepEditor.vue | 579 +++++++ .../journey/BrowserJourneyStepError.spec.ts | 111 ++ .../journey/BrowserJourneyStepError.vue | 151 ++ .../synthetics/journey/RecordJourney.spec.ts | 287 ---- .../synthetics/journey/RecordJourney.vue | 193 --- .../journey/TestIdMisconfiguredNotice.spec.ts | 72 + .../journey/TestIdMisconfiguredNotice.vue | 82 + .../journey/UpgradeJourneyBanner.spec.ts | 83 + .../journey/UpgradeJourneyBanner.vue | 94 ++ .../journey/ZeroAssertionNotice.spec.ts | 65 + .../journey/ZeroAssertionNotice.vue | 88 + .../synthetics/results/EvidencePanel.spec.ts | 255 +++ .../synthetics/results/EvidencePanel.vue | 352 ++++ .../synthetics/attemptViews.spec.ts | 231 +++ .../synthetics/syntheticResultsSchema.spec.ts | 428 ++++- .../synthetics/syntheticResultsSchema.ts | 1070 +++++++++++- .../composables/useSyntheticResults.spec.ts | 67 +- web/src/composables/useSyntheticResults.ts | 158 +- .../composables/useSyntheticsRecorder.spec.ts | 74 +- web/src/composables/useSyntheticsRecorder.ts | 76 +- web/src/constants/synthetics.spec.ts | 72 + web/src/constants/synthetics.ts | 144 +- web/src/locales/languages/de-DE.json | 34 + web/src/locales/languages/en-US.json | 136 +- web/src/locales/languages/es-ES.json | 34 + web/src/locales/languages/fr-FR.json | 34 + web/src/locales/languages/it-IT.json | 34 + web/src/locales/languages/ja-JP.json | 34 + web/src/locales/languages/ko-KR.json | 34 + web/src/locales/languages/nl-NL.json | 34 + web/src/locales/languages/pl-PL.json | 34 + web/src/locales/languages/pt-PT.json | 34 + web/src/locales/languages/ru-RU.json | 34 + web/src/locales/languages/tr-TR.json | 34 + web/src/locales/languages/vi-VN.json | 34 + web/src/locales/languages/zh-CN.json | 34 + web/src/locales/languages/zh-TW.json | 34 + web/src/types/synthetics.ts | 132 +- web/src/utils/synthetics/buildPayload.ts | 12 +- web/src/utils/synthetics/buildV2Steps.spec.ts | 176 ++ web/src/utils/synthetics/buildV2Steps.ts | 168 ++ .../synthetics/deriveLocatorKind.spec.ts | 53 + web/src/utils/synthetics/deriveLocatorKind.ts | 43 + web/src/utils/synthetics/liftJourney.spec.ts | 251 +++ web/src/utils/synthetics/liftJourney.ts | 239 +++ .../utils/synthetics/locatorStability.spec.ts | 114 ++ web/src/utils/synthetics/locatorStability.ts | 87 + .../utils/synthetics/mapRecordedStep.spec.ts | 192 ++- web/src/utils/synthetics/mapRecordedStep.ts | 149 +- web/src/utils/synthetics/stepTarget.spec.ts | 86 + web/src/utils/synthetics/stepTarget.ts | 64 + .../views/synthetics/CreateBrowserTest.vue | 36 +- web/src/views/synthetics/MonitorResults.vue | 6 + web/src/views/synthetics/MonitorRuns.spec.ts | 148 +- web/src/views/synthetics/MonitorRuns.vue | 107 +- web/src/views/synthetics/RunDetail.spec.ts | 91 +- web/src/views/synthetics/RunDetail.vue | 782 +++++---- 80 files changed, 12426 insertions(+), 1330 deletions(-) create mode 100644 src/infra/src/table/migration/m20260730_000001_add_alert_state_to_synthetics_monitors.rs create mode 100644 web/src/components/synthetics/CreateBrowserTest.schema.spec.ts create mode 100644 web/src/components/synthetics/StepEvidence.spec.ts create mode 100644 web/src/components/synthetics/StepEvidence.vue create mode 100644 web/src/components/synthetics/journey/BrowserJourneyAssertion.spec.ts create mode 100644 web/src/components/synthetics/journey/BrowserJourneyAssertion.vue create mode 100644 web/src/components/synthetics/journey/BrowserJourneyLocator.spec.ts create mode 100644 web/src/components/synthetics/journey/BrowserJourneyLocator.vue create mode 100644 web/src/components/synthetics/journey/BrowserJourneyStepEditor.spec.ts create mode 100644 web/src/components/synthetics/journey/BrowserJourneyStepEditor.vue create mode 100644 web/src/components/synthetics/journey/BrowserJourneyStepError.spec.ts create mode 100644 web/src/components/synthetics/journey/BrowserJourneyStepError.vue delete mode 100644 web/src/components/synthetics/journey/RecordJourney.spec.ts delete mode 100644 web/src/components/synthetics/journey/RecordJourney.vue create mode 100644 web/src/components/synthetics/journey/TestIdMisconfiguredNotice.spec.ts create mode 100644 web/src/components/synthetics/journey/TestIdMisconfiguredNotice.vue create mode 100644 web/src/components/synthetics/journey/UpgradeJourneyBanner.spec.ts create mode 100644 web/src/components/synthetics/journey/UpgradeJourneyBanner.vue create mode 100644 web/src/components/synthetics/journey/ZeroAssertionNotice.spec.ts create mode 100644 web/src/components/synthetics/journey/ZeroAssertionNotice.vue create mode 100644 web/src/components/synthetics/results/EvidencePanel.spec.ts create mode 100644 web/src/components/synthetics/results/EvidencePanel.vue create mode 100644 web/src/composables/synthetics/attemptViews.spec.ts create mode 100644 web/src/constants/synthetics.spec.ts create mode 100644 web/src/utils/synthetics/buildV2Steps.spec.ts create mode 100644 web/src/utils/synthetics/buildV2Steps.ts create mode 100644 web/src/utils/synthetics/deriveLocatorKind.spec.ts create mode 100644 web/src/utils/synthetics/deriveLocatorKind.ts create mode 100644 web/src/utils/synthetics/liftJourney.spec.ts create mode 100644 web/src/utils/synthetics/liftJourney.ts create mode 100644 web/src/utils/synthetics/locatorStability.spec.ts create mode 100644 web/src/utils/synthetics/locatorStability.ts create mode 100644 web/src/utils/synthetics/stepTarget.spec.ts create mode 100644 web/src/utils/synthetics/stepTarget.ts diff --git a/src/api/management/src/request/synthetics/mod.rs b/src/api/management/src/request/synthetics/mod.rs index 9f02fb6c41..7013e6834d 100644 --- a/src/api/management/src/request/synthetics/mod.rs +++ b/src/api/management/src/request/synthetics/mod.rs @@ -1113,8 +1113,19 @@ async fn process_ack( ..Default::default() }); - // Notify once per run, not once per job ack. - if resp.run_complete && !resp.destinations.is_empty() { + // Notify once per run, not once per job ack — and only when the check's own + // `alert_if_fails` / `cooldown_mins` settings say so. This used to fire on + // every completed run that had a destination, which is why `alert_if_fails: + // 3` alerted on the first failure and a 30-minute cooldown sent thirty + // notifications. + use o2_enterprise::enterprise::synthetics::job_api::AlertDecision; + let recovery = matches!(resp.alert, AlertDecision::Recovered); + let flaky = matches!(resp.alert, AlertDecision::Flaky); + // A degrading target is `warning` on every run for as long as the condition + // lasts, so this one is throttled by transition upstream, not by cooldown. + let degraded = matches!(resp.alert, AlertDecision::Degraded); + let should_notify = !matches!(resp.alert, AlertDecision::Silent); + if should_notify && !resp.destinations.is_empty() { let notification = openobserve_core::synthetics::CheckNotification { org_id: resp.org_id.clone(), monitor_name: resp.synthetics_name.clone(), @@ -1127,6 +1138,11 @@ async fn process_ack( job_count: resp.job_count as i64, error: error.clone(), checked_at, + recovery, + consecutive_failures: resp.consecutive_failures, + flaky, + degraded, + failing_locations: resp.failing_locations.clone(), }; tokio::spawn(async move { openobserve_core::synthetics::notify_check_result(notification).await; diff --git a/src/config/src/config.rs b/src/config/src/config.rs index 7b2d7835e8..7d357ed529 100644 --- a/src/config/src/config.rs +++ b/src/config/src/config.rs @@ -52,7 +52,7 @@ pub type RwAHashSet = tokio::sync::RwLock>; pub type RwBTreeMap = tokio::sync::RwLock>; // for DDL commands and migrations -pub const DB_SCHEMA_VERSION: u64 = 55; +pub const DB_SCHEMA_VERSION: u64 = 56; pub const DB_SCHEMA_KEY: &str = "/db_schema_version/"; // global version variables diff --git a/src/config/src/meta/synthetics.rs b/src/config/src/meta/synthetics.rs index 8dfb8a2e9d..d0eeced603 100644 --- a/src/config/src/meta/synthetics.rs +++ b/src/config/src/meta/synthetics.rs @@ -619,6 +619,128 @@ pub struct SshAuth { pub secret: String, } +// ── Version-2 step record ───────────────────────────────────────────────────── +// +// v1 steps are untyped JSON with a single `selector` and a recorder-stamped +// `timeout_ms`. v2 replaces that with this typed, server-validated structure. +// +// The envelope is defined ONCE, complete, even though later phases populate +// parts of it: `settle.navigation` (Phase 3), `settle.responses` (Phase 4), +// `assertion` / `optional` / `always_run` (Phase 5). Bumping `steps_version` per +// phase would break deployment skew — `deny_unknown_fields` means an additive +// field from a newer recorder would be refused by an older server. Every block +// except `locator` is optional, with a defined absent-behaviour, which is what +// lets the phases ship independently. + +/// One way to find an element. Ordered most-stable-first inside a bundle. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct LocatorCandidate { + /// "test_attribute" | "role" | "text" | "css" | "xpath" + pub kind: String, + pub value: String, +} + +/// Every way the recorder found to identify one element, plus an optional +/// author pin. Candidates are machine-derived evidence and read-only in the UI; +/// `user_override` is the only channel for author intent, which is what makes +/// "never heal a pinned step" fall out for free. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct StepLocator { + #[serde(default)] + pub candidates: Vec, + /// When set, used exclusively — never falls back to `candidates`. + #[serde(default)] + pub user_override: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SettleNavigation { + pub url_pattern: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SettleResponse { + pub url_pattern: String, + #[serde(default)] + pub method: Option, + /// Advisory by default: a signal that never fires annotates the step rather + /// than failing it. Only an author may set this — the recorder always emits + /// `false`. + #[serde(default)] + pub required: bool, +} + +/// What the page demonstrably did after this step, observed during recording and +/// replayed as explicit wait conditions. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct StepSettle { + #[serde(default)] + pub navigation: Option, + #[serde(default)] + pub responses: Vec, + /// How long settling actually took while recording. Used for reporting + /// ("normally ~2s, today 40s") — never as a timeout. + #[serde(default)] + pub observed_duration_ms: Option, + /// How long this step may spend settling. `None` means the runner's default + /// (30s). + /// + /// This is where a retired hard sleep goes when a journey is lifted + /// (P3.4.3): `wait 30000` becomes a 30s settle budget on the step BEFORE it, + /// so the author's intent — "this step needs longer than usual" — survives + /// while the unconditional sleep does not. + #[serde(default)] + pub budget_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StepAssertion { + pub kind: String, + #[serde(default)] + pub expected: Option, + #[serde(default)] + pub attribute: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserStepV2 { + pub id: String, + pub action: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub locator: Option, + #[serde(default)] + pub value: Option, + #[serde(default)] + pub key: Option, + #[serde(default)] + pub files: Option>, + #[serde(default)] + pub settle: Option, + #[serde(default)] + pub assertion: Option, + /// Failure skips the step and the run continues (cookie banners, popups). + #[serde(default)] + pub optional: bool, + /// Runs even after an earlier step failed (logout, cleanup). + #[serde(default)] + pub always_run: bool, + /// `None` means "use the runner's per-category default". An explicit value + /// is a deliberate author choice, validated into 100..=60000. + #[serde(default)] + pub timeout_ms: Option, +} + /// A (browser, device) pair for browser monitor fan-out. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct BrowserDevice { @@ -633,15 +755,41 @@ pub struct BrowserConfig { #[serde(default = "default_browser_devices")] pub browser_devices: Vec, pub runtime: Option, + /// Which step schema `steps` uses. Explicit, never inferred from shape. + /// Absent means 1, so every stored monitor reads back as v1. + #[serde(default = "default_steps_version")] + pub steps_version: u8, #[serde(default)] pub steps: Vec, #[serde(default)] pub env: Vec, #[serde(default)] pub secrets: Vec, + /// Playwright's page default timeout — NOT a check timeout, despite the + /// name. Every step passes an explicit timeout, so this does not cap the + /// runner's per-category defaults. #[serde(default = "default_browser_timeout_ms")] pub timeout_ms: u32, + /// Wall-clock ceiling for one attempt. Absent means + /// [`DEFAULT_JOURNEY_BUDGET_MS`]. Validated against the job lease so a full + /// retry sequence cannot outlive it — see `validate_browser_config`. + pub journey_budget_ms: Option, pub capture: Option, + /// The DOM attribute the recorder selects on for this monitor. + /// + /// Absent means [`DEFAULT_TEST_ID_ATTR`]. It exists because the attribute is + /// a property of the application under test, not of OpenObserve: Playwright + /// defaults to `data-testid`, O2's own frontend uses `data-test`, and a + /// customer may use `data-qa`, `data-cy` or `data-automation-id`. + /// + /// Getting it wrong is silent. Upstream's generator carries a hardcoded + /// fallback list (`data-testid`, `data-test-id`, `data-test`), so an + /// application outside that list produces NO `test_attribute` candidates at + /// all and every step degrades to role/text/css without any error. + /// + /// Recorded per monitor rather than per org so a journey that was recorded + /// against one application keeps working when another is added. + pub test_id_attr: Option, } /// A recorder-captured secret form value (e.g. a password typed during a login @@ -683,6 +831,10 @@ fn default_browser_timeout_ms() -> u32 { 30_000 } +fn default_steps_version() -> u8 { + 1 +} + fn default_tls_port() -> u16 { 443 } @@ -773,8 +925,177 @@ const SELECTOR_ACTIONS: &[&str] = &[ "setInputFiles", ]; +/// Wall-clock ceiling for ONE browser attempt, in milliseconds. +/// +/// The recorder used to stamp a 10s timeout into every step, so runs were short +/// and nobody needed a budget. With runner-owned defaults (60s navigation and +/// assertions, 30s interactions) and `retries` defaulting to 1, an 18-step +/// journey's worst case goes from ~180s to ~1085s — past the job lease, after +/// which the reaper requeues the job and it runs again. +const DEFAULT_JOURNEY_BUDGET_MS: u32 = 300_000; +const MIN_JOURNEY_BUDGET_MS: u32 = 5_000; +const MAX_JOURNEY_BUDGET_MS: u32 = 900_000; + +/// Lease held on a job while it runs — must stay in sync with `LEASE_SECS` in +/// o2-enterprise `synthetics/dispatcher/mod.rs`, and is the floor the job API +/// applies to every agent lease (`synthetics_jobs::lease_batch`). +/// +/// Applies to every check type, not just browser. Both agents ask for a 300s +/// lease, so any check whose retry sequence runs longer than that has its lease +/// expire while the probe is still working — the reaper then terminates the job +/// and completes the run as an error, and the probe's real result is rejected +/// when it finally acks. The validation below is what keeps every check's worst +/// case inside this number, which is in turn what lets the server lease for it +/// unconditionally. +/// +/// NOTE: the AWS Lambda function timeout must be >= this value, or runs are +/// killed mid-journey. That setting lives outside this repository and cannot be +/// asserted here. 900s is also AWS's maximum, so raising `retries` or +/// `journey_budget_ms` further requires re-deriving all three together. +pub const JOB_LEASE_SECS: i64 = 900; + +/// Ceiling for ONE attempt of a non-browser check, in milliseconds. +/// +/// Net `timeout_ms` was previously unbounded: every protocol config defaults it +/// to 10s, but nothing rejected `timeout_ms: 3_600_000`, so the worst case of a +/// retry sequence had no upper limit and could not be checked against the lease. +const MAX_NET_TIMEOUT_MS: u32 = 300_000; +const MIN_NET_TIMEOUT_MS: u32 = 1_000; + +/// Worst-case wall clock for one leased job, in milliseconds. +/// +/// Retries happen INSIDE the leased job, so the lease has to cover the whole +/// sequence rather than one attempt: `attempts x per_attempt + gaps`. The +/// `multiplier` is for work the probe repeats sequentially within the same job — +/// browser device combos — which the lease also covers. +/// +/// Shared by the browser and protocol paths so the two cannot drift; they differ +/// only in what one attempt costs and whether anything multiplies it. +fn worst_case_run_ms( + per_attempt_ms: u32, + multiplier: i64, + retries: i32, + wait_before_retry_secs: i32, +) -> i64 { + let attempts = i64::from(retries) + 1; + multiplier + * (attempts * i64::from(per_attempt_ms) + + i64::from(retries) * i64::from(wait_before_retry_secs) * 1_000) +} + +/// Bounds a protocol check's `timeout_ms` and its full retry sequence. +/// +/// The browser path has had this since `journey_budget_ms` was introduced; the +/// protocol path never did. Both agents ask for a 300s lease while +/// `timeout_ms` was unbounded and `retries` goes to 3, so a check needing more +/// than the lease was accepted, and then on every single run the reaper +/// terminated the job mid-flight and the probe's real result was thrown away as +/// a stale ack — a passing check reporting an error forever. +/// +/// `timeout_ms` is read from the raw config rather than a typed struct because +/// every protocol config declares the same field with the same serde default, so +/// an absent field legitimately means the default. +fn validate_net_retry_budget( + config: &serde_json::Value, + retries: i32, + wait_before_retry_secs: i32, +) -> Result<(), String> { + let timeout_ms = config + .get("timeout_ms") + .and_then(|v| v.as_u64()) + .and_then(|v| u32::try_from(v).ok()) + .unwrap_or_else(default_timeout_ms); + + if !(MIN_NET_TIMEOUT_MS..=MAX_NET_TIMEOUT_MS).contains(&timeout_ms) { + return Err(format!( + "config.timeout_ms: must be {MIN_NET_TIMEOUT_MS}..={MAX_NET_TIMEOUT_MS}, got {timeout_ms}" + )); + } + + let worst_case_ms = worst_case_run_ms(timeout_ms, 1, retries, wait_before_retry_secs); + if worst_case_ms > JOB_LEASE_SECS * 1_000 { + return Err(format!( + "config: a full retry sequence must fit inside the {JOB_LEASE_SECS}s job lease, but \ + (retries={retries} + 1) x timeout_ms={timeout_ms} + retries x \ + wait_before_retry_secs={wait_before_retry_secs} needs {worst_case_ms}ms. Lower \ + timeout_ms, retries, or wait_before_retry_secs — a check that outlives its lease has \ + its job terminated mid-run and its real result rejected as a stale ack." + )); + } + Ok(()) +} + +/// The complete v2 action vocabulary — exactly Playwright's recorder action +/// model, minus what a monitor cannot use. +/// +/// Deliberately excludes `hover`, `scroll`, `wait`/`waitFor` and `screenshot`: +/// upstream `ActionName` has no counterpart for any of them, so the recorder +/// never emitted one and the extension player could never replay one. They +/// entered journeys only through the manual step editor, and using one aborted +/// replay entirely. `type` and `keydown` are dropped as redundant aliases of +/// `fill` and `press`. +/// +/// Because this set is drawn from Playwright's own model, every stored v2 step +/// is executable by both the probe and the extension player by construction. +const V2_STEP_ACTIONS: &[&str] = &[ + "navigate", "click", "fill", "press", "select", "check", "uncheck", "upload", "assert", +]; + +/// v2 actions that operate on an element and therefore need a locator. +const V2_ELEMENT_ACTIONS: &[&str] = &[ + "click", "fill", "press", "select", "check", "uncheck", "upload", "assert", +]; + +/// Actions retired from the vocabulary (spec X-9, Q-10 / D-6). +/// +/// A stored v1 monitor containing one keeps EXECUTING — validation runs on +/// create and update, never on read — but it cannot be saved again until it is +/// migrated. Rejecting on write while accepting on read is what lets the +/// retirement land without a flag day, and what stops an author unknowingly +/// re-saving a journey whose sleeps are the reason it is flaky. +const RETIRED_STEP_ACTIONS: &[&str] = &["hover", "scroll", "wait", "waitFor", "screenshot"]; + +/// The closed set of assertion kinds (spec P5.1). +/// +/// Closed on purpose: the probe fails an unknown kind rather than passing it, so +/// a typo caught here is an error at save time instead of every run failing — +/// the same reasoning as the HTTP assertion field/operator sets above. +const V2_ASSERTION_KINDS: &[&str] = &[ + "element_visible", + "element_not_visible", + "element_text", + "url_matches", + "page_title", + "element_attribute", +]; + +/// Kinds that ask "is it there?" and so have nothing to compare against. +const V2_VISIBILITY_ASSERTION_KINDS: &[&str] = &["element_visible", "element_not_visible"]; + +/// Kinds that describe the page rather than an element, and so need no locator. +const V2_PAGE_LEVEL_ASSERTION_KINDS: &[&str] = &["url_matches", "page_title"]; + const MAX_STEPS: usize = 50; const MAX_STEPS_JSON_BYTES: usize = 100_000; +/// v2 steps carry up to 5 locator candidates and 5 settle patterns each, roughly +/// 3-4x a v1 step. A maximal 50-step journey lands near 60KB; the cap is set well +/// clear of that. The `config` column is already JSON (jsonb on PostgreSQL), and +/// steps travel over the HTTP resolve/ack bodies rather than the Lambda invoke +/// payload, so neither storage nor transport needs changing. +const MAX_STEPS_JSON_BYTES_V2: usize = 262_144; +const MAX_LOCATOR_CANDIDATES: usize = 5; + +/// The recorder's test-id attribute when a monitor does not set one. +/// +/// `data-test` rather than Playwright's `data-testid`: OpenObserve's own +/// frontend marks interactive elements with it, and self-monitoring is this +/// feature's acceptance test (X-1's o2.introspect monitors). +pub const DEFAULT_TEST_ID_ATTR: &str = "data-test"; + +/// Longest attribute name accepted. A DOM attribute name this long is not a +/// configuration, it is a paste accident. +const MAX_TEST_ID_ATTR_LEN: usize = 64; +const MAX_SETTLE_RESPONSES: usize = 5; const MAX_TAGS: usize = 20; const MAX_VARIABLES: usize = 50; const MAX_BROWSER_DEVICE_COMBOS: usize = 12; @@ -828,7 +1149,61 @@ fn location_allowed(loc: &str, allowed: &[String]) -> bool { && allowed.iter().any(|a| a == &format!("aws-{loc}")) } +/// A save-time warning: the monitor is accepted, but something about it is worth +/// telling the author. +/// +/// Separate from the `Err(String)` channel on purpose. A zero-assertion journey +/// is legitimate — a monitor that only navigates still proves the site answers — +/// so refusing it would be wrong; but it can also click its way through a broken +/// application and pass, which is worth saying out loud (P5.2.4). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SyntheticWarningCode { + NoAssertions, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyntheticWarning { + pub code: SyntheticWarningCode, + /// Machine-readable code plus prose, so a UI can key off one and show the + /// other without re-deriving the wording. + pub message: String, +} + impl Synthetic { + /// Non-blocking problems worth surfacing when a monitor is saved. + /// + /// Deliberately not part of `validate`: everything here is accepted. A caller + /// that ignores this returns exactly the behaviour it had before. + pub fn warnings(&self) -> Vec { + let mut warnings = Vec::new(); + + if self.monitor_type == SyntheticType::Browser { + let has_assertion = self + .config + .get("steps") + .and_then(|s| s.as_array()) + .is_some_and(|steps| { + steps + .iter() + .any(|step| step.get("action").and_then(|a| a.as_str()) == Some("assert")) + }); + if !has_assertion { + warnings.push(SyntheticWarning { + code: SyntheticWarningCode::NoAssertions, + message: "This journey contains no assertions, so it verifies that the steps \ + can be performed but not that the application is working. A journey \ + with no assertion can click its way through a broken page and still \ + pass. Add at least one assertion — for example that a \ + post-login element is visible." + .to_string(), + }); + } + } + + warnings + } + /// Validates a create/update payload. `allowed_*` come from the deployment's /// synthetics capabilities. Empty `allowed_browsers`/`allowed_devices` skip /// the corresponding membership check; an empty `allowed_locations` REJECTS @@ -1049,11 +1424,18 @@ impl Synthetic { allowed_browsers: &[String], allowed_devices: &[String], ) -> Result<(), String> { - match self.monitor_type { + let type_check = match self.monitor_type { SyntheticType::Browser => { let cfg: BrowserConfig = serde_json::from_value(self.config.clone()) .map_err(|e| format!("config: not a valid browser config: {e}"))?; - validate_browser_config(&cfg, &self.frequency, allowed_browsers, allowed_devices) + validate_browser_config( + &cfg, + &self.frequency, + allowed_browsers, + allowed_devices, + self.retries, + self.wait_before_retry_secs, + ) } SyntheticType::Http | SyntheticType::Api => { let cfg: HttpConfig = serde_json::from_value(self.config.clone()) @@ -1118,8 +1500,228 @@ impl Synthetic { // banner check (a rejected auth still proves SSH is up). Ok(()) } + }; + // Type errors first: "not a valid tcp config" is more fundamental than + // anything derived from its fields, and a config that fails to parse has + // no meaningful timeout to report on. + type_check?; + + // Every protocol check gets the same retry-budget-vs-lease bound the + // browser path applies inside `validate_browser_config`. Done here, once, + // rather than in each arm: the arms are per-type and this rule is not, and + // adding a check type should not be able to opt out of it silently. + if self.monitor_type != SyntheticType::Browser { + validate_net_retry_budget(&self.config, self.retries, self.wait_before_retry_secs)?; + } + Ok(()) + } +} + +/// Validates version-2 steps: typed deserialization plus the structural rules +/// that deserialization cannot express. +/// +/// Deserialization does most of the work. Every v2 struct is +/// `deny_unknown_fields`, so a step carrying `code` — or anything else the +/// schema does not know — is refused here. That is what closes the +/// arbitrary-JavaScript path at the schema level rather than relying on the +/// runner to ignore it. +/// Validate one typed assertion (spec P5.1.1–P5.1.3). +/// +/// Follows the shape the HTTP assertions already use: a closed kind set, the +/// values each kind actually needs, and errors naming both the step index and +/// the known set — so a typo cannot silently pass forever. +fn validate_v2_assertion(i: usize, assertion: &StepAssertion) -> Result<(), String> { + if !V2_ASSERTION_KINDS.contains(&assertion.kind.as_str()) { + return Err(format!( + "config.steps[{i}].assertion.kind: unknown kind '{}' (known: {})", + assertion.kind, + V2_ASSERTION_KINDS.join(", ") + )); + } + + // The visibility kinds ask "is it there?" — there is nothing to compare. + if !V2_VISIBILITY_ASSERTION_KINDS.contains(&assertion.kind.as_str()) { + let expected = assertion.expected.as_deref().unwrap_or(""); + if expected.is_empty() { + return Err(format!( + "config.steps[{i}].assertion: kind '{}' requires a non-empty 'expected'", + assertion.kind + )); } } + + if assertion.kind == "element_attribute" + && assertion + .attribute + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + return Err(format!( + "config.steps[{i}].assertion: kind 'element_attribute' requires an 'attribute' name" + )); + } + + Ok(()) +} + +/// Reject the retired actions on a v1 create or update (Q-10.a). +/// +/// The error names every offending index and action rather than the first, +/// because an author fixing them one round-trip at a time is exactly the +/// friction that makes people give up and leave the sleeps in. +fn reject_retired_v1_actions(steps: &[serde_json::Value]) -> Result<(), String> { + let offenders: Vec = steps + .iter() + .enumerate() + .filter_map(|(i, step)| { + let action = step.get("action").and_then(|v| v.as_str())?; + RETIRED_STEP_ACTIONS + .contains(&action) + .then(|| format!("{} ({action})", i + 1)) + }) + .collect(); + + if offenders.is_empty() { + return Ok(()); + } + + Err(format!( + "config.steps: step {} use actions that have been retired ({}). None of them can be \ + replayed — they have no counterpart in the recorder's action model — and a hard sleep is \ + the single largest source of the flakiness this schema exists to remove. Upgrade this \ + journey to steps_version 2, which converts each sleep into a settle budget on the \ + preceding step and drops the unreplayable steps. Existing runs are unaffected until you \ + save.", + offenders.join(", "), + RETIRED_STEP_ACTIONS.join(", ") + )) +} + +fn validate_v2_steps(steps: &[serde_json::Value]) -> Result<(), String> { + let mut seen_ids = std::collections::HashSet::new(); + + for (i, raw) in steps.iter().enumerate() { + let step: BrowserStepV2 = + serde_json::from_value(raw.clone()).map_err(|e| format!("config.steps[{i}]: {e}"))?; + + if step.id.is_empty() { + return Err(format!("config.steps[{i}]: 'id' must not be empty")); + } + if !seen_ids.insert(step.id.clone()) { + return Err(format!( + "config.steps[{i}]: duplicate step id '{}'", + step.id + )); + } + + if !V2_STEP_ACTIONS.contains(&step.action.as_str()) { + return Err(format!( + "config.steps[{i}]: action '{}' is not valid in steps_version 2 (valid: {}). \ + hover, scroll, wait and screenshot have no equivalent in the recorder's action \ + model and cannot be replayed; type and keydown are aliases of fill and press.", + step.action, + V2_STEP_ACTIONS.join(", ") + )); + } + + // The probe opens about:blank and never auto-navigates. + if i == 0 && step.action != "navigate" { + return Err(format!( + "config.steps[0]: first step must be 'navigate', got '{}'", + step.action + )); + } + + if step.action == "navigate" { + let url = step + .url + .as_deref() + .ok_or_else(|| format!("config.steps[{i}]: navigate step missing 'url'"))?; + validate_http_url(&format!("config.steps[{i}].url"), url)?; + } + + // P5.1.4 — an assertion is what an `assert` step IS, and is meaningless + // on any other action. Allowing it elsewhere would create a second, + // invisible place for a journey to state an expectation. + if step.action == "assert" { + let assertion = step.assertion.as_ref().ok_or_else(|| { + format!( + "config.steps[{i}]: 'assert' step requires an 'assertion' (kinds: {})", + V2_ASSERTION_KINDS.join(", ") + ) + })?; + validate_v2_assertion(i, assertion)?; + } else if step.assertion.is_some() { + return Err(format!( + "config.steps[{i}]: 'assertion' is only valid on an 'assert' step, not on '{}'", + step.action + )); + } + + // A page-level assertion is about the address bar or the document title, + // so requiring an element would make it depend on something unrelated + // still being on screen. + let needs_locator = V2_ELEMENT_ACTIONS.contains(&step.action.as_str()) + && !step + .assertion + .as_ref() + .is_some_and(|a| V2_PAGE_LEVEL_ASSERTION_KINDS.contains(&a.kind.as_str())); + + if needs_locator { + let locator = step.locator.as_ref().ok_or_else(|| { + format!( + "config.steps[{i}]: '{}' step requires a 'locator'", + step.action + ) + })?; + if locator.candidates.is_empty() && locator.user_override.is_none() { + return Err(format!( + "config.steps[{i}].locator: needs at least one candidate or a user_override" + )); + } + if locator.candidates.len() > MAX_LOCATOR_CANDIDATES { + return Err(format!( + "config.steps[{i}].locator.candidates: too many ({} > {MAX_LOCATOR_CANDIDATES})", + locator.candidates.len() + )); + } + } + + if (step.action == "fill" || step.action == "select") && step.value.is_none() { + return Err(format!( + "config.steps[{i}]: '{}' step requires a 'value'", + step.action + )); + } + + if let Some(settle) = &step.settle { + if settle.responses.len() > MAX_SETTLE_RESPONSES { + return Err(format!( + "config.steps[{i}].settle.responses: too many ({} > {MAX_SETTLE_RESPONSES})", + settle.responses.len() + )); + } + if let Some(budget) = settle.budget_ms + && !(100..=60_000).contains(&budget) + { + return Err(format!( + "config.steps[{i}].settle.budget_ms: must be 100..=60000, got {budget}" + )); + } + } + + if let Some(timeout) = step.timeout_ms + && !(100..=60_000).contains(&timeout) + { + return Err(format!( + "config.steps[{i}].timeout_ms: must be 100..=60000, got {timeout}" + )); + } + } + + Ok(()) } fn validate_browser_config( @@ -1127,7 +1729,73 @@ fn validate_browser_config( frequency: &SyntheticFrequency, allowed_browsers: &[String], allowed_devices: &[String], + retries: i32, + wait_before_retry_secs: i32, ) -> Result<(), String> { + // ── journey budget vs. job lease ─────────────────────────────────────── + // A browser job holds a lease while it runs (o2-enterprise dispatcher, + // LEASE_SECS). If a run outlives its lease the reaper requeues it and the + // journey EXECUTES AGAIN — duplicate result records, multiplied browser + // cost, and false alerts. Per-step timeouts alone cannot bound this, so the + // whole retry sequence must be checked against the lease up front. + let budget_ms = cfg.journey_budget_ms.unwrap_or(DEFAULT_JOURNEY_BUDGET_MS); + if !(MIN_JOURNEY_BUDGET_MS..=MAX_JOURNEY_BUDGET_MS).contains(&budget_ms) { + return Err(format!( + "config.journey_budget_ms: must be {MIN_JOURNEY_BUDGET_MS}..={MAX_JOURNEY_BUDGET_MS}, got {budget_ms}" + )); + } + // Multiplied by the device count, because the probe runs `browser_devices` + // SEQUENTIALLY INSIDE the leased job (`browser-probe/src/index.ts:124`) — the + // lease covers the whole job, not one device. + // + // Without this the check was per-device while the work was per-job, so a + // perfectly ordinary "desktop + mobile" config computed 605ms of budget, + // passed validation, and then blew its 900s lease on EVERY run: the reaper + // requeued mid-journey, the journey executed again, and that produced + // duplicate result records, doubled browser cost and alerts caused by the + // duplicate. Which is verbatim what the LEASE_SECS comment in + // `dispatcher/mod.rs` was written to prevent. + let devices = i64::try_from(cfg.browser_devices.len().max(1)).unwrap_or(1); + let worst_case_ms = worst_case_run_ms(budget_ms, devices, retries, wait_before_retry_secs); + if worst_case_ms > JOB_LEASE_SECS * 1_000 { + return Err(format!( + "config: a full retry sequence across all browser/device combos must fit inside the \ + {JOB_LEASE_SECS}s job lease, but browser_devices={devices} x (retries={retries} x \ + journey_budget_ms={budget_ms} + wait_before_retry_secs={wait_before_retry_secs}) needs \ + {worst_case_ms}ms. Lower journey_budget_ms, retries, or the number of browser/device \ + combos — a run that outlives its lease is requeued and executed a second time." + )); + } + + // ── recorder test-id attribute ───────────────────────────────────────── + // Validated rather than trusted because it is interpolated into a selector + // (`[="value"]`). A name with a quote or bracket in it would produce a + // selector that silently matches nothing, which is the failure mode this + // whole area exists to remove. + if let Some(attr) = &cfg.test_id_attr { + let trimmed = attr.trim(); + if trimmed.is_empty() { + return Err( + "config.test_id_attr: must not be blank — omit it to use the default".to_string(), + ); + } + if trimmed.len() > MAX_TEST_ID_ATTR_LEN { + return Err(format!( + "config.test_id_attr: too long ({} > {MAX_TEST_ID_ATTR_LEN})", + trimmed.len() + )); + } + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(format!( + "config.test_id_attr: '{trimmed}' is not a valid attribute name \ + (letters, digits, '-' and '_' only)" + )); + } + } + // ── steps ────────────────────────────────────────────────────────────── if cfg.steps.is_empty() { return Err("config.steps: at least one step is required".to_string()); @@ -1141,12 +1809,33 @@ fn validate_browser_config( let steps_bytes = serde_json::to_string(&cfg.steps) .map(|s| s.len()) .unwrap_or(0); - if steps_bytes > MAX_STEPS_JSON_BYTES { + let max_bytes = if cfg.steps_version >= 2 { + MAX_STEPS_JSON_BYTES_V2 + } else { + MAX_STEPS_JSON_BYTES + }; + if steps_bytes > max_bytes { return Err(format!( - "config.steps: serialized steps too large ({steps_bytes} > {MAX_STEPS_JSON_BYTES} bytes)" + "config.steps: serialized steps too large ({steps_bytes} > {max_bytes} bytes)" )); } + // v2 steps are typed and validated separately; v1 keeps its historical + // untyped path byte-for-byte so no stored monitor changes behaviour. + if cfg.steps_version >= 2 { + validate_v2_steps(&cfg.steps)?; + return validate_browser_devices_and_schedule( + cfg, + frequency, + allowed_browsers, + allowed_devices, + ); + } + + // Q-10 / D-6: retired actions are refused on write while stored monitors + // carrying them keep executing on read. + reject_retired_v1_actions(&cfg.steps)?; + let mut seen_ids = std::collections::HashSet::new(); for (i, step) in cfg.steps.iter().enumerate() { let action = step @@ -1210,6 +1899,17 @@ fn validate_browser_config( } } + validate_browser_devices_and_schedule(cfg, frequency, allowed_browsers, allowed_devices) +} + +/// Shared by the v1 and v2 step paths — everything about a browser config that +/// is not the steps themselves. +fn validate_browser_devices_and_schedule( + cfg: &BrowserConfig, + frequency: &SyntheticFrequency, + allowed_browsers: &[String], + allowed_devices: &[String], +) -> Result<(), String> { // ── browser × device combos ──────────────────────────────────────────── if cfg.browser_devices.is_empty() { return Err( @@ -1553,6 +2253,95 @@ mod tests { ) } + fn valid_tcp_synthetic() -> Synthetic { + Synthetic { + name: "db port".to_string(), + monitor_type: SyntheticType::Tcp, + target: "db.example.com".to_string(), + frequency: SyntheticFrequency { + frequency_type: SyntheticFrequencyType::Minutes, + interval: 5, + cron: String::new(), + timezone: None, + }, + locations: vec!["aws-us-east-1".to_string()], + enabled: true, + alert_if_fails: 1, + wait_before_retry_secs: 5, + config: serde_json::json!({ "port": 5432, "timeout_ms": 10000 }), + ..Default::default() + } + } + + #[test] + fn net_retry_sequence_must_fit_the_job_lease() { + let (locs, brs, devs) = allowed(); + // The lease covers the whole retry sequence because retries run inside + // the leased job. 4 x 300s alone is 1200s, past the 900s lease. + let mut s = valid_tcp_synthetic(); + s.config = serde_json::json!({ "port": 5432, "timeout_ms": 300_000 }); + s.retries = 3; + s.wait_before_retry_secs = 0; + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("job lease"), "{err}"); + + // The gaps count too: 3 x 250s = 750s of attempts is fine on its own, + // but not with 300s of waiting between them. + let mut s = valid_tcp_synthetic(); + s.config = serde_json::json!({ "port": 5432, "timeout_ms": 250_000 }); + s.retries = 2; + s.wait_before_retry_secs = 300; + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("job lease"), "{err}"); + } + + #[test] + fn net_timeout_is_bounded_at_all() { + let (locs, brs, devs) = allowed(); + // Was previously unbounded: every protocol config defaults timeout_ms to + // 10s, but nothing rejected an hour, so the worst case had no ceiling to + // check the lease against. + let mut s = valid_tcp_synthetic(); + s.config = serde_json::json!({ "port": 5432, "timeout_ms": 3_600_000 }); + s.retries = 0; + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.starts_with("config.timeout_ms:"), "{err}"); + } + + #[test] + fn net_defaults_and_ordinary_configs_still_validate() { + let (locs, brs, devs) = allowed(); + // A config with no timeout_ms at all must use the serde default rather + // than 0, which would otherwise trip the new minimum. + let mut s = valid_tcp_synthetic(); + s.config = serde_json::json!({ "port": 5432 }); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + + // The worst realistic config: max retries and a 60s timeout is 240s of + // attempts plus 90s of gaps — comfortably inside the lease. This must + // keep passing, or the bound is too tight to be shipped. + let mut s = valid_tcp_synthetic(); + s.config = serde_json::json!({ "port": 5432, "timeout_ms": 60_000 }); + s.retries = 3; + s.wait_before_retry_secs = 30; + assert!( + s.validate(&locs, &brs, &devs, true).is_ok(), + "{:?}", + s.validate(&locs, &brs, &devs, true) + ); + } + + #[test] + fn worst_case_counts_attempts_gaps_and_the_multiplier() { + // retries=0 is one attempt and no gaps. + assert_eq!(worst_case_run_ms(10_000, 1, 0, 30), 10_000); + // retries=2 is three attempts and two gaps. + assert_eq!(worst_case_run_ms(10_000, 1, 2, 30), 30_000 + 60_000); + // The multiplier applies to the whole sequence, not one attempt — the + // probe repeats the entire retry sequence per device inside one lease. + assert_eq!(worst_case_run_ms(10_000, 2, 2, 30), 2 * (30_000 + 60_000)); + } + #[test] fn test_validate_ok() { let (locs, brs, devs) = allowed(); @@ -1563,6 +2352,653 @@ mod tests { ); } + // ── Version-2 steps (spec Phase 2, P2.1/P2.2) ──────────────────────────── + // v2 replaces the untyped step blob with a typed, server-validated one. The + // envelope is defined ONCE with its complete field set, and later phases + // populate blocks that ship optional from day one (settle, assertion, + // optional/always_run). Bumping the version per phase would break + // deployment skew: v2 rejects unknown fields, so an additive field from a + // newer recorder would be refused by an older server. + + fn v2_synthetic(steps: serde_json::Value) -> Synthetic { + let mut s = valid_browser_synthetic(); + s.config["steps_version"] = serde_json::json!(2); + s.config["steps"] = steps; + s + } + + fn v2_click_step() -> serde_json::Value { + serde_json::json!({ + "id": "s2", + "action": "click", + "name": "Sign In", + "locator": { + "candidates": [ + { "kind": "test_attribute", "value": "[data-test=\"login-sign-in\"]" }, + { "kind": "role", "value": "role=button[name=\"Sign In\"]" } + ] + } + }) + } + + fn v2_nav_step() -> serde_json::Value { + serde_json::json!({ + "id": "s1", + "action": "navigate", + "name": "Open", + "url": "https://example.com" + }) + } + + #[test] + fn test_v2_minimal_journey_accepted() { + let (locs, brs, devs) = allowed(); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), v2_click_step()])); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + // ── Recorder test-id attribute ─────────────────────────────────────────── + // The attribute is a property of the application under test, not of O2, and + // getting it wrong is SILENT: upstream's generator falls back to a hardcoded + // list, so an app outside it produces no test_attribute candidates at all. + + #[test] + fn test_test_id_attr_absent_is_valid() { + let (locs, brs, devs) = allowed(); + let s = valid_browser_synthetic(); + assert!(s.config.get("test_id_attr").is_none()); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + #[test] + fn test_test_id_attr_accepts_real_attribute_names() { + let (locs, brs, devs) = allowed(); + for attr in [ + "data-test", + "data-testid", + "data-qa", + "data-cy", + "data_e2e", + "id", + ] { + let mut s = valid_browser_synthetic(); + s.config["test_id_attr"] = serde_json::json!(attr); + assert!( + s.validate(&locs, &brs, &devs, true).is_ok(), + "{attr} should be accepted: {:?}", + s.validate(&locs, &brs, &devs, true) + ); + } + } + + #[test] + fn test_test_id_attr_rejects_blank() { + // Blank would silently mean "everything" once interpolated into + // `[="value"]`. Omitting the field is how you ask for the default. + let (locs, brs, devs) = allowed(); + let mut s = valid_browser_synthetic(); + s.config["test_id_attr"] = serde_json::json!(" "); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("test_id_attr"), "{err}"); + } + + #[test] + fn test_test_id_attr_rejects_selector_injection() { + // The value is interpolated into a selector, so a quote or bracket would + // produce one that silently matches nothing. + let (locs, brs, devs) = allowed(); + for bad in ["data-test=\"x\"", "data test", "data]test", "a*b"] { + let mut s = valid_browser_synthetic(); + s.config["test_id_attr"] = serde_json::json!(bad); + assert!( + s.validate(&locs, &brs, &devs, true).is_err(), + "{bad} should be rejected" + ); + } + } + + #[test] + fn test_test_id_attr_rejects_absurd_length() { + let (locs, brs, devs) = allowed(); + let mut s = valid_browser_synthetic(); + s.config["test_id_attr"] = serde_json::json!("d".repeat(MAX_TEST_ID_ATTR_LEN + 1)); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("too long"), "{err}"); + } + + #[test] + fn test_v1_unchanged_when_steps_version_absent() { + // Regression guard: every stored monitor must keep validating exactly as + // before. v1 steps carry a bare `selector` and no locator bundle. + let (locs, brs, devs) = allowed(); + let s = valid_browser_synthetic(); + assert!(s.config.get("steps_version").is_none()); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + #[test] + fn test_v2_rejects_unknown_fields() { + // This is the mechanism that closes the arbitrary-code hole: anything + // the schema does not know about is refused, so `code` cannot ride along + // under a different name either. + let (locs, brs, devs) = allowed(); + let mut step = v2_click_step(); + step["surprise"] = serde_json::json!("hello"); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("surprise"), "{err}"); + } + + #[test] + fn test_v2_rejects_code_field() { + let (locs, brs, devs) = allowed(); + let mut step = v2_click_step(); + step["code"] = serde_json::json!("await page.evaluate(() => fetch('http://evil'))"); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("code"), "{err}"); + } + + #[test] + fn test_v2_accepts_exactly_the_nine_action_vocabulary() { + let (locs, brs, devs) = allowed(); + for action in [ + "click", "fill", "press", "select", "check", "uncheck", "upload", + ] { + let mut step = v2_click_step(); + step["action"] = serde_json::json!(action); + if action == "fill" || action == "select" { + step["value"] = serde_json::json!("x"); + } + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + assert!( + s.validate(&locs, &brs, &devs, true).is_ok(), + "{action} should be accepted: {:?}", + s.validate(&locs, &brs, &devs, true) + ); + } + } + + #[test] + fn test_v2_rejects_retired_actions() { + // Upstream Playwright's recorder action model has no counterpart for any + // of these, so the recorder never emitted one and the player could never + // replay one. `type`/`keydown` are redundant aliases of fill/press. + let (locs, brs, devs) = allowed(); + for action in [ + "hover", + "scroll", + "wait", + "waitFor", + "screenshot", + "type", + "keydown", + ] { + let mut step = v2_click_step(); + step["action"] = serde_json::json!(action); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!( + err.contains(action) || err.contains("action"), + "{action} should be rejected in v2, got: {err}" + ); + } + } + + #[test] + fn test_v1_rejects_retired_actions_on_write_naming_every_offender() { + // Q-10.a: the error names EVERY offending step index and action, not the + // first — fixing them one round-trip at a time is the friction that makes + // authors give up and leave the sleeps in. + let (locs, brs, devs) = allowed(); + let mut s = valid_browser_synthetic(); + s.config["steps"] = serde_json::json!([ + { "id": "s1", "action": "navigate", "url": "https://example.com" }, + { "id": "s2", "action": "wait", "timeout_ms": 30000 }, + { "id": "s3", "action": "click", "selector": "#go" }, + { "id": "s4", "action": "hover", "selector": "#menu" } + ]); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("2 (wait)"), "{err}"); + assert!(err.contains("4 (hover)"), "{err}"); + assert!( + !err.contains("3 ("), + "the healthy click must not be named: {err}" + ); + // Q-10.b: the remedy is offered in the message, not left to be guessed. + assert!(err.contains("steps_version 2"), "{err}"); + } + + #[test] + fn test_v1_retired_actions_still_deserialize_so_stored_monitors_keep_running() { + // D-6: rejection is a WRITE-path rule. Nothing calls `validate` when a + // monitor is loaded to be executed, and a stored journey carrying a + // legacy `wait` must keep running until its author migrates it. + let cfg: BrowserConfig = serde_json::from_value(serde_json::json!({ + "steps": [ + { "id": "s1", "action": "navigate", "url": "https://example.com" }, + { "id": "s2", "action": "wait", "timeout_ms": 30000 } + ] + })) + .expect("a stored v1 config with a retired action must still load"); + assert_eq!(cfg.steps.len(), 2); + assert_eq!(cfg.steps[1]["action"], "wait"); + assert_eq!(cfg.steps_version, 1); + } + + fn v2_assert_step(assertion: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "id": "s3", + "action": "assert", + "name": "Profile visible", + "locator": { + "candidates": [ + { "kind": "test_attribute", "value": "[data-test=\"header-my-account-profile-icon\"]" } + ] + }, + "assertion": assertion + }) + } + + // ── Phase 5 — typed assertions (T5-1…T5-4) ────────────────────────────── + + #[test] + fn test_v2_every_assertion_kind_is_accepted() { + let (locs, brs, devs) = allowed(); + for kind in V2_ASSERTION_KINDS { + let mut assertion = serde_json::json!({ "kind": kind }); + if !V2_VISIBILITY_ASSERTION_KINDS.contains(kind) { + assertion["expected"] = serde_json::json!("something"); + } + if *kind == "element_attribute" { + assertion["attribute"] = serde_json::json!("href"); + } + let s = v2_synthetic(serde_json::json!([ + v2_nav_step(), + v2_click_step(), + v2_assert_step(assertion) + ])); + assert!( + s.validate(&locs, &brs, &devs, true).is_ok(), + "kind '{kind}' must be accepted: {:?}", + s.validate(&locs, &brs, &devs, true) + ); + } + } + + #[test] + fn test_v2_unknown_assertion_kind_is_rejected_naming_the_known_set() { + let (locs, brs, devs) = allowed(); + let s = v2_synthetic(serde_json::json!([ + v2_nav_step(), + v2_assert_step(serde_json::json!({ "kind": "element_vissible" })) + ])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("element_vissible"), "{err}"); + assert!( + err.contains("element_visible"), + "the known set must be listed: {err}" + ); + assert!(err.contains("steps[1]"), "the index must be named: {err}"); + } + + #[test] + fn test_v2_expected_is_required_except_for_the_visibility_kinds() { + let (locs, brs, devs) = allowed(); + for kind in ["element_text", "url_matches", "page_title"] { + let s = v2_synthetic(serde_json::json!([ + v2_nav_step(), + v2_assert_step(serde_json::json!({ "kind": kind })) + ])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("expected"), "kind '{kind}': {err}"); + } + // …and an empty string is not a value. + let s = v2_synthetic(serde_json::json!([ + v2_nav_step(), + v2_assert_step(serde_json::json!({ "kind": "element_text", "expected": "" })) + ])); + assert!(s.validate(&locs, &brs, &devs, true).is_err()); + } + + #[test] + fn test_v2_element_attribute_requires_an_attribute_name() { + let (locs, brs, devs) = allowed(); + let s = v2_synthetic(serde_json::json!([ + v2_nav_step(), + v2_assert_step(serde_json::json!({ "kind": "element_attribute", "expected": "/web/" })) + ])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("attribute"), "{err}"); + } + + #[test] + fn test_v2_assertion_is_required_on_assert_and_forbidden_elsewhere() { + let (locs, brs, devs) = allowed(); + + let mut bare = v2_assert_step(serde_json::json!({ "kind": "element_visible" })); + bare.as_object_mut().unwrap().remove("assertion"); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), bare])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("requires an 'assertion'"), "{err}"); + + let mut click = v2_click_step(); + click["assertion"] = serde_json::json!({ "kind": "element_visible" }); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), click])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("only valid on an 'assert' step"), "{err}"); + } + + #[test] + fn test_v2_page_level_assertions_need_no_locator() { + // A statement about the address bar must not depend on some unrelated + // element still being on screen. + let (locs, brs, devs) = allowed(); + let step = serde_json::json!({ + "id": "s3", + "action": "assert", + "assertion": { "kind": "url_matches", "expected": "**/web/**" } + }); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), v2_click_step(), step])); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + #[test] + fn test_a_zero_assertion_journey_is_accepted_with_a_machine_readable_warning() { + // P5.2.4 — accepted, not refused: a monitor that only navigates still + // proves the site answers. The warning is what stops it being mistaken + // for a monitor that checks something. + let (locs, brs, devs) = allowed(); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), v2_click_step()])); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + let warnings = s.warnings(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].code, SyntheticWarningCode::NoAssertions); + + let with_assert = v2_synthetic(serde_json::json!([ + v2_nav_step(), + v2_click_step(), + v2_assert_step(serde_json::json!({ "kind": "element_visible" })) + ])); + assert!(with_assert.warnings().is_empty()); + } + + // ── Phase 3 — settle budget (T3-6's storage side) ─────────────────────── + + #[test] + fn test_v2_settle_budget_is_range_checked() { + let (locs, brs, devs) = allowed(); + let mut step = v2_click_step(); + step["settle"] = serde_json::json!({ "budget_ms": 30000 }); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step.clone()])); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + + step["settle"] = serde_json::json!({ "budget_ms": 90000 }); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("settle.budget_ms"), "{err}"); + } + + #[test] + fn test_v2_settle_navigation_and_responses_round_trip() { + let (locs, brs, devs) = allowed(); + let mut step = v2_click_step(); + step["settle"] = serde_json::json!({ + "navigation": { "url_pattern": "**/web/**" }, + "responses": [ + { "url_pattern": "**/auth/login", "method": "POST", "required": false } + ], + "observed_duration_ms": 1800, + "budget_ms": 30000 + }); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + #[test] + fn test_v2_element_action_requires_a_locator() { + let (locs, brs, devs) = allowed(); + let mut step = v2_click_step(); + step.as_object_mut().unwrap().remove("locator"); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("locator"), "{err}"); + } + + #[test] + fn test_v2_user_override_satisfies_the_locator_requirement() { + let (locs, brs, devs) = allowed(); + let step = serde_json::json!({ + "id": "s2", + "action": "click", + "name": "Sign In", + "locator": { + "candidates": [], + "user_override": { "kind": "css", "value": "#pinned" } + } + }); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + #[test] + fn test_v2_caps_locator_candidates_at_five() { + let (locs, brs, devs) = allowed(); + let candidates: Vec<_> = (0..6) + .map(|i| serde_json::json!({ "kind": "css", "value": format!("#c{i}") })) + .collect(); + let mut step = v2_click_step(); + step["locator"]["candidates"] = serde_json::json!(candidates); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("candidates"), "{err}"); + } + + #[test] + fn test_v2_caps_settle_response_patterns_at_five() { + let (locs, brs, devs) = allowed(); + let responses: Vec<_> = (0..6) + .map(|i| serde_json::json!({ "url_pattern": format!("**/api/{i}") })) + .collect(); + let mut step = v2_click_step(); + step["settle"] = serde_json::json!({ "responses": responses }); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("responses"), "{err}"); + } + + #[test] + fn test_v2_accepts_a_full_settle_block() { + // Defined in Phase 2, populated by Phases 3 and 4. It must validate now + // so a newer recorder is never refused by an older server (spec V-2). + let (locs, brs, devs) = allowed(); + let mut step = v2_click_step(); + step["settle"] = serde_json::json!({ + "navigation": { "url_pattern": "**/web/**" }, + "responses": [ { "url_pattern": "**/auth/login", "method": "POST", "required": false } ], + "observed_duration_ms": 2300 + }); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + #[test] + fn test_v2_accepts_assertion_and_flow_control_blocks() { + let (locs, brs, devs) = allowed(); + let step = serde_json::json!({ + "id": "s3", + "action": "assert", + "name": "Profile visible", + "locator": { "candidates": [ { "kind": "test_attribute", "value": "[data-test=\"p\"]" } ] }, + "assertion": { "kind": "element_visible" }, + "optional": true, + "always_run": false + }); + let s = v2_synthetic(serde_json::json!([v2_nav_step(), step])); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + #[test] + fn test_v2_first_step_must_be_navigate() { + let (locs, brs, devs) = allowed(); + let s = v2_synthetic(serde_json::json!([v2_click_step()])); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("navigate"), "{err}"); + } + + #[test] + fn test_v2_raises_the_steps_size_cap() { + // v2 steps are heavier (up to 5 candidates + settle patterns each), so + // the cap moves 100_000 -> 262_144. A payload between the two must be + // rejected as v1 and accepted as v2. + let (locs, brs, devs) = allowed(); + let filler = "x".repeat(150_000); + let big_steps = serde_json::json!([ + v2_nav_step(), + { + "id": "s2", + "action": "click", + "name": filler, + "locator": { "candidates": [ { "kind": "css", "value": "#a" } ] } + } + ]); + let v2 = v2_synthetic(big_steps.clone()); + assert!( + v2.validate(&locs, &brs, &devs, true).is_ok(), + "v2 should allow ~150KB" + ); + + let mut v1 = valid_browser_synthetic(); + v1.config["steps"] = serde_json::json!([ + { "id": "s1", "action": "navigate", "url": "https://example.com" }, + { "id": "s2", "action": "click", "selector": "#a", "name": "x".repeat(150_000) } + ]); + let err = v1.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("too large"), "{err}"); + } + + // ── Journey budget vs. job lease (spec P1.5.4 / T1-5) ──────────────────── + // Raising per-step timeouts and defaulting retries to 1 takes the worst-case + // run to ~1085s against a 300s job lease. An overrunning job has its lease + // expire, is requeued by the reaper, and RUNS AGAIN — duplicate results, + // multiplied browser cost, and a new class of false alert caused by the fix. + // The invariant that keeps a full retry sequence inside its lease: + // (retries + 1) * journey_budget_ms + retries * wait_before_retry_secs * 1000 + // <= LEASE_SECS * 1000 + + #[test] + fn test_browser_journey_budget_within_lease_ok() { + let (locs, brs, devs) = allowed(); + let mut s = valid_browser_synthetic(); + s.retries = 1; + s.wait_before_retry_secs = 5; + s.config["journey_budget_ms"] = serde_json::json!(300_000); + // 2 * 300s + 5s = 605s, inside the 900s lease. + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + #[test] + fn test_browser_journey_budget_exceeding_lease_rejected() { + let (locs, brs, devs) = allowed(); + let mut s = valid_browser_synthetic(); + s.retries = 3; + s.wait_before_retry_secs = 5; + s.config["journey_budget_ms"] = serde_json::json!(300_000); + // 4 * 300s + 15s = 1215s — well past the 900s lease. + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + // The error must name all three inputs, or an operator cannot tell which + // one to change. + assert!(err.contains("journey_budget_ms"), "{err}"); + assert!(err.contains("retries"), "{err}"); + assert!(err.contains("lease"), "{err}"); + } + + // The invariant above was per-DEVICE while the work is per-JOB: the probe runs + // `browser_devices` sequentially inside the leased job, so the real worst case + // is multiplied by the combo count. Missing that, a perfectly ordinary + // desktop+mobile config passed validation and then blew its lease on EVERY + // run — which is exactly the duplicate-execution failure the lease was raised + // to 900s to prevent. + #[test] + fn test_browser_lease_budget_is_multiplied_by_device_count() { + let (locs, brs, devs) = allowed(); + let mut s = valid_browser_synthetic(); + s.retries = 1; + s.wait_before_retry_secs = 5; + s.config["journey_budget_ms"] = serde_json::json!(300_000); + + // One device: 1 * (2 * 300s + 5s) = 605s, inside the 900s lease. + s.config["browser_devices"] = + serde_json::json!([{"browser": "chromium", "device": "desktop"}]); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + + // Two devices: 2 * 605s = 1210s. Same per-device budget, twice the work. + s.config["browser_devices"] = serde_json::json!([ + {"browser": "chromium", "device": "desktop"}, + {"browser": "chromium", "device": "mobile"}, + ]); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + // Every lever the operator can pull must be named, device count included — + // it is the one they are most likely to be able to give up. + assert!(err.contains("browser_devices"), "{err}"); + assert!(err.contains("journey_budget_ms"), "{err}"); + assert!(err.contains("retries"), "{err}"); + assert!(err.contains("lease"), "{err}"); + } + + #[test] + fn test_browser_two_devices_fit_with_a_smaller_budget() { + // The rejection above must be escapable by lowering the budget, not only + // by dropping a device. + let (locs, brs, devs) = allowed(); + let mut s = valid_browser_synthetic(); + s.retries = 1; + s.wait_before_retry_secs = 5; + // 2 devices * (2 * 200s + 5s) = 810s, inside 900s. + s.config["journey_budget_ms"] = serde_json::json!(200_000); + s.config["browser_devices"] = serde_json::json!([ + {"browser": "chromium", "device": "desktop"}, + {"browser": "chromium", "device": "mobile"}, + ]); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + #[test] + fn test_browser_journey_budget_boundary_is_inclusive() { + let (locs, brs, devs) = allowed(); + let mut s = valid_browser_synthetic(); + s.retries = 1; + s.wait_before_retry_secs = 0; + // Exactly 2 * 450s = 900s — equal to the lease, which is permitted. + s.config["journey_budget_ms"] = serde_json::json!(450_000); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + + // One millisecond more per attempt tips it over. + s.config["journey_budget_ms"] = serde_json::json!(450_001); + assert!(s.validate(&locs, &brs, &devs, true).is_err()); + } + + #[test] + fn test_browser_journey_budget_defaults_when_absent() { + let (locs, brs, devs) = allowed(); + let mut s = valid_browser_synthetic(); + s.retries = 1; + s.wait_before_retry_secs = 5; + // No journey_budget_ms in config — the 300s default applies and fits. + assert!(s.config.get("journey_budget_ms").is_none()); + assert!(s.validate(&locs, &brs, &devs, true).is_ok()); + } + + #[test] + fn test_browser_journey_budget_bounds() { + let (locs, brs, devs) = allowed(); + let mut s = valid_browser_synthetic(); + s.retries = 0; + s.config["journey_budget_ms"] = serde_json::json!(500); + let err = s.validate(&locs, &brs, &devs, true).unwrap_err(); + assert!(err.contains("journey_budget_ms"), "{err}"); + } + #[test] fn test_validate_empty_location_registry_rejected() { // An empty registry must reject — jobs for such a check would land diff --git a/src/config/src/metrics.rs b/src/config/src/metrics.rs index ee95dc562f..da8a7b3522 100644 --- a/src/config/src/metrics.rs +++ b/src/config/src/metrics.rs @@ -163,6 +163,46 @@ pub static INGEST_PARQUET_FILES: Lazy = Lazy::new(|| { ) .expect("Metric created") }); +/// Checks waiting to be leased, per location and pool. +/// +/// The other half of queue-lag visibility. A result record carries `scheduled_ts` +/// and `started_ts`, so the delay of work that RAN is already derivable — but a +/// check nobody leased produces no record at all, so the backlog it sits in is +/// invisible from the results side by construction. Without this, "the queue is +/// backed up" and "concurrency fixed it" are both unfalsifiable. +pub static SYNTHETICS_PENDING_JOBS: Lazy = Lazy::new(|| { + IntGaugeVec::new( + Opts::new( + "synthetics_pending_jobs", + "Number of synthetics checks pending lease.".to_owned() + HELP_SUFFIX, + ) + .namespace(NAMESPACE) + .const_labels(create_const_labels()), + &["location", "pool"], + ) + .expect("Metric created") +}); + +/// Age of the oldest check still waiting, in seconds, per location and pool. +/// +/// Reported alongside the count because they fail differently: a large count that +/// drains every tick is throughput, while a small count whose oldest entry keeps +/// ageing is a location that has stopped being served at all — one agent down, or +/// no agent ever polling that pool. The count alone cannot tell those apart. +pub static SYNTHETICS_OLDEST_PENDING_AGE_SECONDS: Lazy = Lazy::new(|| { + IntGaugeVec::new( + Opts::new( + "synthetics_oldest_pending_age_seconds", + "Age of the oldest synthetics check pending lease, in seconds.".to_owned() + + HELP_SUFFIX, + ) + .namespace(NAMESPACE) + .const_labels(create_const_labels()), + &["location", "pool"], + ) + .expect("Metric created") +}); + pub static INGEST_PACK_FILES: Lazy = Lazy::new(|| { IntGaugeVec::new( Opts::new( @@ -2050,6 +2090,12 @@ fn register_metrics(registry: &Registry) { registry .register(Box::new(INGEST_PARQUET_FILES.clone())) .expect("Metric registered"); + registry + .register(Box::new(SYNTHETICS_PENDING_JOBS.clone())) + .expect("Metric registered"); + registry + .register(Box::new(SYNTHETICS_OLDEST_PENDING_AGE_SECONDS.clone())) + .expect("Metric registered"); registry .register(Box::new(INGEST_PACK_FILES.clone())) .expect("Metric registered"); @@ -2650,6 +2696,8 @@ mod tests { let _ = INGEST_ERRORS.clone(); let _ = INGEST_WAL_USED_BYTES.clone(); let _ = INGEST_PARQUET_FILES.clone(); + let _ = SYNTHETICS_PENDING_JOBS.clone(); + let _ = SYNTHETICS_OLDEST_PENDING_AGE_SECONDS.clone(); let _ = INGEST_PACK_FILES.clone(); let _ = INGEST_PACK_SEGMENTS.clone(); let _ = INGEST_WAL_WRITE_BYTES.clone(); diff --git a/src/core/src/synthetics.rs b/src/core/src/synthetics.rs index ed92144c8b..d3c1ff8f75 100644 --- a/src/core/src/synthetics.rs +++ b/src/core/src/synthetics.rs @@ -35,6 +35,29 @@ pub struct CheckNotification { pub job_count: i64, pub error: Option, pub checked_at: i64, + /// This message closes an incident rather than opening one. + /// + /// Mandatory once `cooldown_mins` exists: with a cooldown, silence no + /// longer means "recovered", it means "possibly still broken and inside + /// the window". A recovery message is the only thing that closes it. + pub recovery: bool, + /// How many runs in a row had failed when this fired. 0 on a recovery. + pub consecutive_failures: i32, + /// The run recovered by retrying. Informational, not an incident. + pub flaky: bool, + /// The target is reachable but degrading — a certificate inside its warning + /// window, or a failing SFTP probe on a host that authenticated. + /// + /// Kept distinct from `flaky` because they arrive as the same `warning` + /// status: a flaky run fixed itself and needs no action, a degrading one + /// will not fix itself and needs action before it becomes an outage. + pub degraded: bool, + /// Locations that did not pass, worst first. + /// + /// Previously absent entirely, so a six-location check with one broken + /// region could only say "the check is failing" and the reader had to open + /// the UI to find out where. + pub failing_locations: Vec, } /// Fires once per run (when all jobs have completed) for non-passing runs. @@ -44,7 +67,13 @@ pub struct CheckNotification { /// HTTP webhooks, an HTML card for email, plain text for SNS. #[cfg(feature = "enterprise")] pub async fn notify_check_result(n: CheckNotification) { - if n.status == "passed" || n.status == "up" { + // A passing run is worth sending exactly once: when it ends an incident + // somebody was already told about. Otherwise it is a confirmation nobody + // asked for. + // + // A flaky run reports as `warning`, not `passed`, so it is not caught here — + // the decision to send it was already made upstream against `cooldown_mins`. + if !n.recovery && (n.status == "passed" || n.status == "up") { return; } @@ -66,12 +95,25 @@ pub async fn notify_check_result(n: CheckNotification) { DestinationType::Http(_) => build_slack_json(&n), }; - let subject = format!( - "[OpenObserve Synthetics] {} {} is {}", - status_emoji(&n.status), - n.monitor_name, - n.status.to_uppercase() - ); + let subject = if n.recovery { + format!( + "[OpenObserve Synthetics] ✅ {} has RECOVERED", + n.monitor_name + ) + } else if n.degraded { + // Not "is WARNING": the point of the message is that this + // needs action before it becomes an outage. + format!("[OpenObserve Synthetics] 🟡 {} is DEGRADED", n.monitor_name) + } else if n.flaky { + format!("[OpenObserve Synthetics] 🔁 {} is FLAKY", n.monitor_name) + } else { + format!( + "[OpenObserve Synthetics] {} {} is {}", + status_emoji(&n.status), + n.monitor_name, + n.status.to_uppercase() + ) + }; if let Err(e) = crate::alerts::alert::dispatch_notification(destination_type, &subject, msg) .await @@ -92,6 +134,7 @@ pub async fn notify_check_result(n: CheckNotification) { #[cfg(feature = "enterprise")] fn status_emoji(status: &str) -> &'static str { match status { + "recovered" => "✅", "failed" | "down" => "🔴", "warning" => "🟡", "error" => "⚠️", @@ -102,6 +145,23 @@ fn status_emoji(status: &str) -> &'static str { /// What the status means, in operator language — differs per status. #[cfg(feature = "enterprise")] fn status_headline(n: &CheckNotification) -> String { + if n.recovery { + return format!("{} has recovered", n.monitor_name); + } + // `warning` covers two unrelated things, and they need opposite responses: + // a flaky run already fixed itself, a degrading target will not. + if n.degraded { + return format!( + "{} is reachable but degrading — this needs attention before it fails", + n.monitor_name + ); + } + if n.flaky { + return format!( + "{} passed only after retries (flaky) — it recovered on its own", + n.monitor_name + ); + } match n.status.as_str() { "warning" => format!("{} passed only after retries (flaky)", n.monitor_name), "error" => format!( @@ -140,6 +200,25 @@ fn run_url(n: &CheckNotification) -> String { ) } +#[cfg(feature = "enterprise")] +/// "2 of 6: mumbai, frankfurt" — or just the count when we could not attribute. +/// +/// Answers "which region is broken", which the message previously could not: it +/// carried only how many locations were checked, so a one-of-six failure and a +/// six-of-six outage read identically. +fn locations_line(n: &CheckNotification) -> String { + let total = if n.job_count > 0 { n.job_count } else { 1 }; + if n.failing_locations.is_empty() { + return total.to_string(); + } + format!( + "{} of {}: {}", + n.failing_locations.len(), + total, + n.failing_locations.join(", ") + ) +} + /// Slack-compatible webhook payload (also renders fine in Teams/Discord-style /// webhooks that accept a `text` field). #[cfg(feature = "enterprise")] @@ -150,10 +229,7 @@ fn build_slack_json(n: &CheckNotification) -> String { String::new(), format!("*Monitor:* {} ({})", n.monitor_name, n.monitor_type), format!("*Target:* {}", n.target), - format!( - "*Locations checked:* {}", - if n.job_count > 0 { n.job_count } else { 1 } - ), + format!("*Locations:* {}", locations_line(n)), ]; if let Some(e) = n.error.as_deref().filter(|e| !e.is_empty()) { lines.push(format!("*Error:* ```{e}```")); @@ -174,10 +250,7 @@ fn build_plain_text(n: &CheckNotification) -> String { format!("Monitor: {} ({})", n.monitor_name, n.monitor_type), format!("Target: {}", n.target), format!("Status: {}", n.status), - format!( - "Locations checked: {}", - if n.job_count > 0 { n.job_count } else { 1 } - ), + format!("Locations: {}", locations_line(n)), ]; if let Some(e) = n.error.as_deref().filter(|e| !e.is_empty()) { lines.push(format!("Error: {e}")); @@ -191,10 +264,14 @@ fn build_plain_text(n: &CheckNotification) -> String { #[cfg(feature = "enterprise")] fn build_email_html(n: &CheckNotification) -> String { // TODO: update with a better template for all the checks - let color = match n.status.as_str() { - "warning" => "#b58105", - "error" => "#b45309", - _ => "#c62828", + let color = if n.recovery { + "#2e7d32" + } else { + match n.status.as_str() { + "warning" => "#b58105", + "error" => "#b45309", + _ => "#c62828", + } }; let error_row = match n.error.as_deref().filter(|e| !e.is_empty()) { Some(e) => format!( @@ -214,7 +291,7 @@ fn build_email_html(n: &CheckNotification) -> String { {target} Status {status} - Locations checked + Locations {jobs} Time {checked_at} @@ -230,7 +307,7 @@ fn build_email_html(n: &CheckNotification) -> String { mtype = html_escape(&n.monitor_type), target = html_escape(&n.target), status = n.status.to_uppercase(), - jobs = if n.job_count > 0 { n.job_count } else { 1 }, + jobs = html_escape(&locations_line(n)), checked_at = checked_at_utc(n.checked_at), url = run_url(n), ) diff --git a/src/infra/src/table/entity/synthetics_monitors.rs b/src/infra/src/table/entity/synthetics_monitors.rs index 30c6bc7436..a1d01d77fa 100644 --- a/src/infra/src/table/entity/synthetics_monitors.rs +++ b/src/infra/src/table/entity/synthetics_monitors.rs @@ -33,6 +33,21 @@ pub struct Model { /// Denormalised status from the most recent completed check. /// 0=Unknown, 1=Up, 2=Warning, 3=Down pub last_check_status: i32, + /// Runs that have failed back to back; reset to 0 by a pass. Compared + /// against `settings.alert_if_fails` to decide whether to notify. + pub consecutive_failures: i32, + /// When a notification was last sent, in microseconds. 0 = never. + /// Compared against `settings.cooldown_mins`. + pub last_alert_at: i64, + /// Whether the check is currently in the alerting state. Without it, + /// "recovered" cannot be told from "was never alerting" — and once a + /// cooldown exists, silence stops meaning recovery. + pub alerting: bool, + /// When a degradation was last reported, in microseconds. 0 = not currently + /// degraded. Separate from `last_alert_at` because a degradation persists — + /// a certificate is `warning` on every run for weeks — so it needs + /// transition-based suppression rather than a time window. + pub degraded_notified_at: i64, pub owner: Option, pub created_at: i64, pub updated_at: i64, @@ -69,6 +84,10 @@ mod tests { next_run_at: 0, last_triggered_at: 0, last_check_status: 0, + consecutive_failures: 0, + last_alert_at: 0, + alerting: false, + degraded_notified_at: 0, owner: None, created_at: 1750000000000000, updated_at: 1750000000000000, diff --git a/src/infra/src/table/migration/m20260730_000001_add_alert_state_to_synthetics_monitors.rs b/src/infra/src/table/migration/m20260730_000001_add_alert_state_to_synthetics_monitors.rs new file mode 100644 index 0000000000..ef4fd264ad --- /dev/null +++ b/src/infra/src/table/migration/m20260730_000001_add_alert_state_to_synthetics_monitors.rs @@ -0,0 +1,168 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Add the alert state that `alert_if_fails` and `cooldown_mins` need in order +//! to mean anything. +//! +//! Both settings are validated on save, stored, and delivered to the probe — +//! and were read by nothing. Every completed run with a destination notified, +//! so `alert_if_fails: 3` alerted on the first failure and a `cooldown_mins` of +//! 30 sent thirty notifications in thirty minutes. +//! +//! Answering "is this the third consecutive failure?" and "have thirty minutes +//! passed since I last spoke?" needs memory BETWEEN runs; a single ack knows +//! only its own outcome. Three columns, all defaulted so existing rows need no +//! backfill: +//! - `consecutive_failures` — runs that failed back to back, reset by a pass. Compared against +//! `alert_if_fails`. +//! - `last_alert_at` — when a notification was last sent, in microseconds. Compared against +//! `cooldown_mins`. 0 = never. +//! - `alerting` — whether the check is currently in the alerting state. This is what makes a +//! recovery notification possible: without it "recovered" cannot be told from "was never +//! alerting", and once a cooldown exists, silence stops meaning recovery. +//! - `degraded_notified_at` — when a degradation was last reported, in microseconds. 0 = not +//! currently degraded. Degradation needs TRANSITION-based suppression, not time-based: a +//! certificate inside a 30-day warning window is `warning` on every single run, so notifying +//! per run is unusable while treating it as healthy means it never notifies at all until the +//! certificate actually expires — after the outage it existed to prevent. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // `synthetics` is created from the entity definition, so on a fresh + // database these columns already exist by the time this runs. SQLite + // ignores the `IF NOT EXISTS` guard on `ADD COLUMN`, so guard + // explicitly with `has_column` to stay idempotent across backends. + if !manager.has_column(TABLE, "consecutive_failures").await? { + manager + .alter_table( + Table::alter() + .table(Synthetics::Table) + .add_column_if_not_exists( + ColumnDef::new(Synthetics::ConsecutiveFailures) + .integer() + .not_null() + .default(0), + ) + .to_owned(), + ) + .await?; + } + if !manager.has_column(TABLE, "last_alert_at").await? { + manager + .alter_table( + Table::alter() + .table(Synthetics::Table) + .add_column_if_not_exists( + ColumnDef::new(Synthetics::LastAlertAt) + .big_integer() + .not_null() + .default(0), + ) + .to_owned(), + ) + .await?; + } + if !manager.has_column(TABLE, "alerting").await? { + manager + .alter_table( + Table::alter() + .table(Synthetics::Table) + .add_column_if_not_exists( + ColumnDef::new(Synthetics::Alerting) + .boolean() + .not_null() + .default(false), + ) + .to_owned(), + ) + .await?; + } + if !manager.has_column(TABLE, "degraded_notified_at").await? { + manager + .alter_table( + Table::alter() + .table(Synthetics::Table) + .add_column_if_not_exists( + ColumnDef::new(Synthetics::DegradedNotifiedAt) + .big_integer() + .not_null() + .default(0), + ) + .to_owned(), + ) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // sea-query has no `drop_column_if_exists`, so the same guard applies in + // reverse — a partially-applied `up` must still roll back cleanly. + for (name, column) in [ + ("consecutive_failures", Synthetics::ConsecutiveFailures), + ("last_alert_at", Synthetics::LastAlertAt), + ("alerting", Synthetics::Alerting), + ("degraded_notified_at", Synthetics::DegradedNotifiedAt), + ] { + if manager.has_column(TABLE, name).await? { + manager + .alter_table( + Table::alter() + .table(Synthetics::Table) + .drop_column(column) + .to_owned(), + ) + .await?; + } + } + Ok(()) + } +} + +const TABLE: &str = "synthetics"; + +// The table is `synthetics`, NOT `synthetics_monitors` — the entity module is +// named after the concept but declares `table_name = "synthetics"`. DeriveIden +// renders the variant name, so this enum must match the create migration's +// (m20260707_000001), or the ALTER hits a table that does not exist. +#[derive(DeriveIden)] +enum Synthetics { + Table, + ConsecutiveFailures, + LastAlertAt, + Alerting, + DegradedNotifiedAt, +} + +#[cfg(test)] +mod tests { + use sea_orm_migration::MigrationName; + + use super::*; + + #[test] + fn test_migration_name() { + assert_eq!( + Migration.name(), + "m20260730_000001_add_alert_state_to_synthetics_monitors" + ); + } +} diff --git a/src/infra/src/table/migration/mod.rs b/src/infra/src/table/migration/mod.rs index 0895738945..0395dc6570 100644 --- a/src/infra/src/table/migration/mod.rs +++ b/src/infra/src/table/migration/mod.rs @@ -142,6 +142,7 @@ mod m20260723_000001_add_env_version_to_gen_ai_agents; mod m20260724_000001_add_name_is_default_to_synthetics_probe_tokens; mod m20260724_000002_add_token_id_to_synthetics_agents; mod m20260728_000001_create_workflows_associations_table; +mod m20260730_000001_add_alert_state_to_synthetics_monitors; pub struct Migrator; @@ -273,6 +274,7 @@ impl MigratorTrait for Migrator { Box::new(m20260724_000001_add_name_is_default_to_synthetics_probe_tokens::Migration), Box::new(m20260724_000002_add_token_id_to_synthetics_agents::Migration), Box::new(m20260728_000001_create_workflows_associations_table::Migration), + Box::new(m20260730_000001_add_alert_state_to_synthetics_monitors::Migration), ] } } diff --git a/src/infra/src/table/synthetics_jobs.rs b/src/infra/src/table/synthetics_jobs.rs index f6e89dd0e9..716e049c24 100644 --- a/src/infra/src/table/synthetics_jobs.rs +++ b/src/infra/src/table/synthetics_jobs.rs @@ -79,7 +79,8 @@ pub struct LeasedRow { pub metadata: String, } -/// Returned by `dead_letter_expired` for each job that exhausted all retries. +/// Returned by `dead_letter_expired` for each job it terminated. The caller owns +/// completing the job's run — see `reason` for which of the three ways it ended. #[derive(Debug)] pub struct DeadLetteredRow { pub id: String, @@ -90,6 +91,47 @@ pub struct DeadLetteredRow { pub dispatch_attempts: i32, pub run_id: String, pub metadata: String, + pub reason: DeadLetterReason, +} + +/// Why a job was terminated without a probe result. +/// +/// All three end the same way — the run is completed with Error — but they are +/// different operational problems, and collapsing them into one message costs +/// the reader the only clue about which one they have. `NeverDispatched` means +/// no probe polled that location at all (a dead private agent); the other two +/// mean a probe took the job and went away. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeadLetterReason { + /// A probe held the lease, never acked, and has no dispatch attempts left. + AttemptsExhausted, + /// The lease expired and the job is now past `valid_until`. Requeueing it + /// would produce a result stamped for a window that has already closed, so + /// the next scheduled run is the right retry, not this one. + Expired, + /// Never claimed by anything before `valid_until` passed. + NeverDispatched, +} + +/// Classifies a row selected by `dead_letter_expired`. Split out from the query +/// so the mapping is testable without a database. +/// +/// `status` is on the `synthetics_jobs` scale (0=Pending, 1=Leased). +pub fn dead_letter_reason( + status: i32, + dispatch_attempts: i32, + max_attempts: i32, +) -> DeadLetterReason { + if status == 0 { + // Pending and past its window: nothing ever leased it, so the attempt + // count says nothing about why. A leased row with 0 attempts would be a + // different story, which is why status is checked first. + DeadLetterReason::NeverDispatched + } else if dispatch_attempts >= max_attempts { + DeadLetterReason::AttemptsExhausted + } else { + DeadLetterReason::Expired + } } // ── Scheduler: enqueue ──────────────────────────────────────────────────────── @@ -190,12 +232,29 @@ pub async fn drain_monitor( Ok(res.rows_affected()) } +/// Hard ceiling on one lease call, whatever the caller asks for. +/// +/// Well above every real caller — the dispatcher takes 10, a Go agent +/// `min(4 x NumCPU, 16)`, a browser agent 1 — so it never binds in normal +/// operation. It exists so that no single probe can drain the queue, by +/// misconfiguration or otherwise. +const MAX_LEASE_BATCH: i64 = 100; + // ── Dispatcher: lease ───────────────────────────────────────────────────────── /// Leases up to `limit` pending checks from a pool. /// /// Three steps: SELECT candidate IDs → UPDATE status/lease fields → SELECT /// full rows. No raw SQL, works on SQLite and Postgres. +/// +/// `lease_secs` is a floor request, not the decision. Both probes hardcode 300s +/// client-side while a check's retry sequence — which runs *inside* the leased +/// job — is bounded only by `JOB_LEASE_SECS`, so an under-lease means the lease +/// expires while the probe is still working: the reaper terminates the job, +/// completes the run as an error, and the probe's real result is then rejected as +/// a stale ack. A client cannot be trusted to know how long its job may take, so +/// the server raises any request up to the budget the config validation already +/// guarantees every check fits inside. pub async fn lease_batch( conn: &C, pool: &str, @@ -205,8 +264,16 @@ pub async fn lease_batch( lease_secs: i64, browser: Option, ) -> Result, errors::Error> { + let lease_secs = lease_secs.max(config::meta::synthetics::JOB_LEASE_SECS); let lease_expires_at = now_us + lease_secs * 1_000_000; + // `limit` arrives from a client and is cast to u64 below, where a negative + // wraps to ~1.8e19 and the query becomes unbounded — one agent would lease the + // entire pending queue for its pool and then hold a lease on every row of it. + // Reachable from a single mistyped env var, so it is clamped here rather than + // trusted: a probe does not get to decide how much of the queue it may take. + let limit = limit.clamp(1, MAX_LEASE_BATCH); + // Step 1: pick candidate IDs. // // `browser` narrows the candidate set to jobs the caller can actually run, @@ -298,31 +365,90 @@ pub async fn lease_batch( /// Returns the `run_id` of the updated row (for callers to increment run counter). /// /// Status values: 3=Passed, 4=Failed, 5=Warning, 6=Error (dispatch error). +/// +/// Returns `Some(run_id)` when the ack applied, and **`None` when it did not** — +/// the job does not exist, it was no longer Claimed, or it is now held by someone +/// else. The caller must treat `None` as "do not touch run accounting". +/// +/// `claimed_by` is the acking probe's agent id. `None` means the probe did not +/// send one, which is the **compatibility window**: a probe built before this +/// change acks without it, and rejecting those would drop every result until both +/// probes are redeployed. Such an ack falls back to the status guard alone, i.e. +/// exactly the behaviour before this change — so an old probe is no worse off, +/// and a new one gets the ownership guard immediately. The window closes by +/// making this argument non-optional once both probes have shipped. pub async fn ack_complete( conn: &C, job_id: &str, status: i32, result_json: Option<&str>, now_us: i64, + claimed_by: Option<&str>, ) -> Result, errors::Error> { - let sql = r#" - UPDATE synthetics_jobs - SET status = $1, result = $2, completed_at = $3 - WHERE id = $4 - "#; - conn.execute(Statement::from_sql_and_values( - conn.get_database_backend(), - sql, - [ - Value::from(status), - result_json - .map(Value::from) - .unwrap_or(Value::from(None::)), - Value::from(now_us), - Value::from(job_id.to_owned()), - ], - )) - .await?; + // `AND status = 1` makes the ack idempotent: only a job still in the Claimed + // state can be completed. Without it a duplicate ack succeeded, the caller + // called `increment_jobs_done` a second time for one job, `jobs_done` + // overshot `job_count`, and the run was declared complete on a PARTIAL set of + // results — the roll-up status computed from whichever jobs happened to have + // finished. + // + // Duplicate acks are reachable today without any concurrency work: the + // aws-sdk-lambda client retries a timed-out `RequestResponse` invoke, which + // re-executes a check that is already running. + // + // The status guard alone does NOT cover reaper reassignment: the reaper puts + // the row back to Pending, another agent leases it, and the row is at status 1 + // again — so the EVICTED holder's late ack still matches and overwrites the + // new holder's result, or lands before it and gets overwritten. Either way one + // job produced two results and `increment_jobs_done` ran twice for it. + // + // `claimed_by` closes that: only the agent the row is currently leased to can + // complete it. The lease already stamps `claimed_by`, so this compares against + // what the server itself recorded rather than anything the probe asserts. + let (owner_clause, values) = match claimed_by { + Some(agent) => ( + " AND claimed_by = $5", + vec![ + Value::from(status), + result_json + .map(Value::from) + .unwrap_or(Value::from(None::)), + Value::from(now_us), + Value::from(job_id.to_owned()), + Value::from(agent.to_owned()), + ], + ), + None => ( + "", + vec![ + Value::from(status), + result_json + .map(Value::from) + .unwrap_or(Value::from(None::)), + Value::from(now_us), + Value::from(job_id.to_owned()), + ], + ), + }; + let sql = format!( + "UPDATE synthetics_jobs SET status = $1, result = $2, completed_at = $3 \ + WHERE id = $4 AND status = 1{owner_clause}" + ); + let applied = conn + .execute(Statement::from_sql_and_values( + conn.get_database_backend(), + &sql, + values, + )) + .await?; + + // `None` means "this ack did not apply" — the caller must NOT increment the + // run counter. Returning the run_id anyway is what made the guard above + // pointless in an earlier draft: the SELECT below succeeds regardless of + // whether the UPDATE matched. + if applied.rows_affected() == 0 { + return Ok(None); + } // Fetch run_id so caller can call synthetics_runs::increment_jobs_done. let select_sql = "SELECT run_id FROM synthetics_jobs WHERE id = $1"; @@ -342,7 +468,16 @@ pub async fn ack_complete( // ── Reaper ──────────────────────────────────────────────────────────────────── -/// Resets expired leases back to Pending (status=0) when dispatch_attempts < max_attempts. +/// Resets expired leases back to Pending (status=0) when the job has dispatch +/// attempts left *and* is still inside its validity window. +/// +/// The `valid_until` guard is what makes the retry budget real. Without it this +/// requeues a job whose window has already closed, and `prune_stale` — which +/// runs later in the same reaper tick — deletes it again immediately, because +/// `valid_until` is one interval (60s for a 1-minute check) while a lease is +/// 300s or more. So the row went Pending → deleted within one tick, +/// `dispatch_attempts` never got past 1, `max_attempts` was unreachable, and +/// the run it belonged to was never completed by anyone. pub async fn requeue_expired( conn: &C, now_us: i64, @@ -353,6 +488,7 @@ pub async fn requeue_expired( SET status = 0, claimed_by = NULL, claimed_at = NULL, lease_expires_at = NULL WHERE status = 1 AND lease_expires_at < $1 + AND valid_until >= $1 AND dispatch_attempts < $2 "#; let res = conn @@ -365,20 +501,38 @@ pub async fn requeue_expired( Ok(res.rows_affected()) } -/// Marks permanently failed rows as Dead (status=2) when dispatch_attempts >= max_attempts. -/// Returns the affected rows so the reaper can update monitor status and write to streams. +/// Marks every job that can no longer produce a probe result as Dead (status=2) +/// and returns the rows the caller must now account for. +/// +/// Covers all three terminal shapes, because each one leaves a run that nobody +/// else will ever finish: +/// +/// - leased, lease expired, no attempts left → `AttemptsExhausted` +/// - leased, lease expired, past `valid_until` → `Expired` (a retry would report against a closed +/// window; the next scheduled run is the right retry) +/// - pending, past `valid_until` → `NeverDispatched`, previously the business of `prune_stale`, +/// which DELETEd the row and so destroyed the only record that the run was still owed a job +/// +/// The caller **must** complete the run for every row returned. The per-row +/// compare-and-swap below is what makes that safe to do exactly once: the reaper +/// runs on every alert_manager node, and a bulk `UPDATE ... WHERE id IN (...)` +/// after a separate SELECT lets two nodes both believe they terminated the same +/// job. That was harmless while the caller only wrote to a stream; it is not +/// harmless now that the caller increments a run counter, because a double +/// increment pushes `jobs_done` past `job_count` and completes the run early, +/// with a result that is missing a location. pub async fn dead_letter_expired( conn: &C, now_us: i64, max_attempts: i32, ) -> Result, errors::Error> { - // Step 1: find candidates before marking them dead. + // Step 1: find candidates before marking them dead. `status` comes back so + // the CAS can require the row not to have moved under us. let select_sql = r#" - SELECT id, synthetics_id, synthetics_name, org_id, location, dispatch_attempts, run_id, metadata + SELECT id, synthetics_id, synthetics_name, org_id, location, dispatch_attempts, run_id, metadata, status FROM synthetics_jobs - WHERE status = 1 - AND lease_expires_at < $1 - AND dispatch_attempts >= $2 + WHERE (status = 1 AND lease_expires_at < $1 AND (dispatch_attempts >= $2 OR valid_until < $1)) + OR (status = 0 AND valid_until < $1) "#; let rows = conn .query_all(Statement::from_sql_and_values( @@ -392,40 +546,45 @@ pub async fn dead_letter_expired( return Ok(vec![]); } - let dead: Vec = rows + let candidates: Vec<(DeadLetteredRow, i32)> = rows .into_iter() .filter_map(|row| { - Some(DeadLetteredRow { - id: row.try_get::("", "id").ok()?, - synthetics_id: row.try_get("", "synthetics_id").ok()?, - synthetics_name: row.try_get("", "synthetics_name").ok()?, - org_id: row.try_get("", "org_id").ok()?, - location: row.try_get("", "location").ok()?, - dispatch_attempts: row.try_get("", "dispatch_attempts").ok()?, - run_id: row.try_get("", "run_id").ok()?, - metadata: row.try_get("", "metadata").unwrap_or_default(), - }) + let status: i32 = row.try_get("", "status").ok()?; + let dispatch_attempts: i32 = row.try_get("", "dispatch_attempts").ok()?; + let reason = dead_letter_reason(status, dispatch_attempts, max_attempts); + Some(( + DeadLetteredRow { + id: row.try_get::("", "id").ok()?, + synthetics_id: row.try_get("", "synthetics_id").ok()?, + synthetics_name: row.try_get("", "synthetics_name").ok()?, + org_id: row.try_get("", "org_id").ok()?, + location: row.try_get("", "location").ok()?, + dispatch_attempts, + run_id: row.try_get("", "run_id").ok()?, + metadata: row.try_get("", "metadata").unwrap_or_default(), + reason, + }, + status, + )) }) .collect(); - // Step 2: mark them all dead. - let ids: Vec = dead.iter().map(|r| Value::from(r.id.clone())).collect(); - let placeholders: String = ids - .iter() - .enumerate() - .map(|(i, _)| format!("${}", i + 1)) - .collect::>() - .join(", "); - let update_sql = format!( - "UPDATE synthetics_jobs SET status = 2 WHERE id IN ({})", - placeholders - ); - conn.execute(Statement::from_sql_and_values( - conn.get_database_backend(), - &update_sql, - ids, - )) - .await?; + // Step 2: claim each row individually. Only rows this call actually + // transitioned are returned, so only one node ever accounts for a job. + let update_sql = "UPDATE synthetics_jobs SET status = 2 WHERE id = $1 AND status = $2"; + let mut dead = Vec::with_capacity(candidates.len()); + for (row, prev_status) in candidates { + let res = conn + .execute(Statement::from_sql_and_values( + conn.get_database_backend(), + update_sql, + [Value::from(row.id.clone()), Value::from(prev_status)], + )) + .await?; + if res.rows_affected() == 1 { + dead.push(row); + } + } Ok(dead) } @@ -441,6 +600,20 @@ pub enum DispatchFailureOutcome { /// via the dispatcher's existing `mark_failure`), so writing an /// intermediate status here would just be overwritten and wastes a query. DeadLettered, + /// The job is no longer Claimed — it has already been completed by a probe + /// ack, or reassigned to another holder. **The caller must do nothing:** no + /// requeue, no failure record, no run accounting. + /// + /// Reachable whenever a dispatch is judged failed *after* the probe already + /// reported: a Lambda that acks and then panics returns `FunctionError`, and + /// the SDK can also fail reading a response for an invocation that ran fine. + /// Without this the requeue reset a finished job to Pending, it was leased + /// again, and the check ran a second time — producing a duplicate result and a + /// second `increment_jobs_done` for one job. + /// + /// Enforced by the compiler rather than a test: the dispatcher matches all three + /// variants with no wildcard arm, so adding this one made "handle it" mandatory. + AlreadySettled, } /// Called when dispatching a leased job failed *before* the probe could run @@ -458,24 +631,161 @@ pub async fn fail_dispatch( max_attempts: i32, ) -> Result { if current_attempts < max_attempts { + // `AND status = 1` is what keeps this from resurrecting finished work. A + // dispatch can be judged failed after the probe has already acked — a + // Lambda that reports and then panics is exactly that — and without the + // guard the requeue reset a COMPLETED job to Pending, so it was leased and + // run again: duplicate result, and `increment_jobs_done` twice for one job. let sql = r#" UPDATE synthetics_jobs SET status = 0, claimed_by = NULL, claimed_at = NULL, lease_expires_at = NULL - WHERE id = $1 + WHERE id = $1 AND status = 1 "#; - conn.execute(Statement::from_sql_and_values( - conn.get_database_backend(), - sql, - [Value::from(job_id.to_owned())], - )) - .await?; + let applied = conn + .execute(Statement::from_sql_and_values( + conn.get_database_backend(), + sql, + [Value::from(job_id.to_owned())], + )) + .await?; + if applied.rows_affected() == 0 { + return Ok(DispatchFailureOutcome::AlreadySettled); + } Ok(DispatchFailureOutcome::Requeued) } else { + // Same check on the terminal branch, for the same reason: the caller's + // `mark_failure` would otherwise write a dispatch error over a real result. + // `ack_complete` would refuse it, but the caller also writes to the results + // stream and increments the run, and neither of those can be taken back. + let still_claimed = Entity::find() + .select_only() + .column(Column::Id) + .filter(Column::Id.eq(job_id)) + .filter(Column::Status.eq(1i32)) + .into_tuple::() + .one(conn) + .await?; + if still_claimed.is_none() { + return Ok(DispatchFailureOutcome::AlreadySettled); + } Ok(DispatchFailureOutcome::DeadLettered) } } /// Deletes stale Pending rows whose valid_until has passed (missed entirely). +/// Locations of this run's jobs that did not pass, worst first. +/// +/// A notification for a completed run previously said only "the check is +/// failing" — `CheckNotification` had no location field at all, and +/// `AckResponse.location` is the location of whichever job happened to ack LAST, +/// which would name an arbitrary one. With six locations and one broken, the +/// message could not say which. +/// +/// Read from `synthetics_jobs` rather than aggregated on the run row because the +/// run keeps only `MAX(status)` — one integer, no per-location detail. Costs one +/// query, on run completion only, not per ack. +/// +/// Job status ints are the `synthetics_jobs` scale: 3=Passed, 4=Failed, +/// 5=Warning, 6=Error. Anything other than Passed is returned. +pub async fn failing_locations( + conn: &C, + run_id: &str, +) -> Result, errors::Error> { + let rows = Entity::find() + .select_only() + .column(Column::Location) + .column(Column::Status) + .filter(Column::RunId.eq(run_id)) + .filter(Column::Status.ne(3)) + .into_tuple::<(String, i32)>() + .all(conn) + .await?; + let mut rows = rows; + // Worst first: Error(6) > Warning(5) > Failed(4). A reader scanning the first + // line of a message should see the most severe location, not the + // alphabetically first. + rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + let mut out: Vec = Vec::with_capacity(rows.len()); + for (loc, _) in rows { + if !out.contains(&loc) { + out.push(loc); + } + } + Ok(out) +} + +/// One location+pool's share of the pending queue. +#[derive(Debug, PartialEq, Eq)] +pub struct PendingBacklogRow { + pub location: String, + pub pool: String, + /// Checks still waiting to be leased. + pub pending: i64, + /// `scheduled_ts` of the oldest one, in microseconds. The caller turns this + /// into an age; doing it here would bake in a clock reading the caller may + /// already have. + pub oldest_scheduled_ts: i64, +} + +/// Per-location, per-pool count of checks waiting to be leased, with the oldest +/// one's scheduled time. +/// +/// Counts only rows that are still leasable — Pending and inside `valid_until`. +/// Rows past their window are excluded on purpose: they are the reaper's to +/// terminate and counting them would report a backlog that no amount of probe +/// capacity can drain, which is a different problem wearing the same number. +pub async fn pending_backlog( + conn: &C, + now_us: i64, +) -> Result, errors::Error> { + let sql = r#" + SELECT location, pool, COUNT(*) AS pending, MIN(scheduled_ts) AS oldest_scheduled_ts + FROM synthetics_jobs + WHERE status = 0 AND valid_until > $1 + GROUP BY location, pool + "#; + let rows = conn + .query_all(Statement::from_sql_and_values( + conn.get_database_backend(), + sql, + [Value::from(now_us)], + )) + .await?; + + Ok(rows + .into_iter() + .filter_map(|row| { + Some(PendingBacklogRow { + location: row.try_get("", "location").ok()?, + pool: row.try_get("", "pool").ok()?, + // SQLite hands COUNT back as i32 on some builds and i64 on others, + // so try the wider type first and fall back rather than dropping + // the row and reporting an empty backlog. + pending: row + .try_get::("", "pending") + .or_else(|_| row.try_get::("", "pending").map(i64::from)) + .unwrap_or(0), + // Falls back to `now_us`, i.e. age 0 — NOT to 0, which is the + // epoch and would publish a ~55-year-old backlog and page whoever + // is on call for a column we simply failed to decode. + oldest_scheduled_ts: row + .try_get::("", "oldest_scheduled_ts") + .or_else(|_| row.try_get::("", "oldest_scheduled_ts").map(i64::from)) + .unwrap_or(now_us), + }) + }) + .collect()) +} + +/// Backstop for pending rows past `valid_until`. +/// +/// `dead_letter_expired` runs earlier in the same reaper tick and moves these to +/// Dead after completing their runs, so in normal operation this matches nothing. +/// It stays as a floor: if the dead-letter path ever fails to claim a row, the +/// row is still removed rather than left to be re-examined every 30 seconds +/// forever. A row this deletes is a run that will not complete, so a non-zero +/// count here is a bug signal, not routine housekeeping — the reaper logs it at +/// warn for that reason. pub async fn prune_stale(conn: &C, now_us: i64) -> Result { let sql = r#" DELETE FROM synthetics_jobs @@ -538,4 +848,75 @@ mod tests { assert_eq!(row.run_id, "3Fzn001XXXXXXXXXXXXXXXX"); assert_eq!(row.dispatch_attempts, 1); } + + const MAX: i32 = 3; + + #[test] + fn a_lease_limit_is_clamped_before_it_reaches_the_query() { + // `limit` is cast to u64 in the query. A negative wraps to ~1.8e19, making + // the LIMIT unbounded — one agent leases the entire pending queue for its + // pool and holds a 900s lease on every row. Reachable from one mistyped + // env var, so the server clamps rather than trusting the client. + assert_eq!((-4i64).clamp(1, MAX_LEASE_BATCH), 1); + assert_eq!(0i64.clamp(1, MAX_LEASE_BATCH), 1); + // Real callers are far below the ceiling and pass through untouched: + // dispatcher 10, Go agent min(4 x NumCPU, 16), browser agent 1. + for asked in [1i64, 10, 16, 25] { + assert_eq!(asked.clamp(1, MAX_LEASE_BATCH), asked); + } + // And nobody drains the queue by asking louder. + assert_eq!(i64::MAX.clamp(1, MAX_LEASE_BATCH), MAX_LEASE_BATCH); + } + + #[test] + fn pending_backlog_row_carries_location_pool_and_the_oldest_entry() { + // The oldest entry is the half that distinguishes a deep queue that drains + // every tick from a location nothing is serving at all. + let row = PendingBacklogRow { + location: "private-dc1".to_string(), + pool: "private-dc1-pool".to_string(), + pending: 42, + oldest_scheduled_ts: 1_750_000_000_000_000, + }; + assert_eq!(row.pending, 42); + assert_eq!(row.oldest_scheduled_ts, 1_750_000_000_000_000); + } + + #[test] + fn pending_row_is_never_dispatched_whatever_its_attempt_count() { + // Status decides this, not the counter. A Pending row past `valid_until` + // was never leased for the window that just closed, so reporting + // "did not respond after N attempts" would blame a probe that was never + // asked. Both counts must classify the same way. + assert_eq!( + dead_letter_reason(0, 0, MAX), + DeadLetterReason::NeverDispatched + ); + assert_eq!( + dead_letter_reason(0, MAX, MAX), + DeadLetterReason::NeverDispatched + ); + } + + #[test] + fn leased_row_separates_a_spent_budget_from_a_closed_window() { + // At or over the budget: retries are genuinely exhausted. + assert_eq!( + dead_letter_reason(1, MAX, MAX), + DeadLetterReason::AttemptsExhausted + ); + assert_eq!( + dead_letter_reason(1, MAX + 1, MAX), + DeadLetterReason::AttemptsExhausted + ); + // Under the budget: attempts were left, but the window closed first, so + // this is the case `requeue_expired` deliberately declines to retry. + // Before the `valid_until` guard, this row was requeued and then deleted + // by `prune_stale` in the same tick, which is why it needs its own name. + assert_eq!(dead_letter_reason(1, 0, MAX), DeadLetterReason::Expired); + assert_eq!( + dead_letter_reason(1, MAX - 1, MAX), + DeadLetterReason::Expired + ); + } } diff --git a/src/infra/src/table/synthetics_monitors.rs b/src/infra/src/table/synthetics_monitors.rs index ed882559bd..7930a3a1a8 100644 --- a/src/infra/src/table/synthetics_monitors.rs +++ b/src/infra/src/table/synthetics_monitors.rs @@ -438,6 +438,94 @@ pub async fn update_last_check_status( Ok(()) } +/// The alert bookkeeping a completed run needs in order to decide whether to +/// notify. Read and written by the ack path only. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct AlertState { + /// Runs that failed back to back. Reset to 0 by a pass. + pub consecutive_failures: i32, + /// When a notification was last sent, in microseconds. 0 = never. + pub last_alert_at: i64, + /// Whether the check is currently in the alerting state. + pub alerting: bool, + /// When a degradation was last reported, in microseconds. 0 = not currently + /// degraded. Degradation persists for as long as the certificate takes to + /// expire, so it is suppressed by transition rather than by a time window. + pub degraded_notified_at: i64, +} + +/// Reads the alert state without pulling the whole monitor (config, secrets and +/// step definitions are several KB, and this runs on every ack). +pub async fn get_alert_state( + conn: &C, + id: &str, +) -> Result, errors::Error> { + let row = Entity::find_by_id(id) + .select_only() + .column(Column::ConsecutiveFailures) + .column(Column::LastAlertAt) + .column(Column::Alerting) + .column(Column::DegradedNotifiedAt) + .into_tuple::<(i32, i64, bool, i64)>() + .one(conn) + .await?; + Ok(row.map( + |(consecutive_failures, last_alert_at, alerting, degraded_notified_at)| AlertState { + consecutive_failures, + last_alert_at, + alerting, + degraded_notified_at, + }, + )) +} + +/// Writes the alert state back after a run completes. +/// Writes the alert state back, but only if it still holds `expected`. +/// +/// Returns `true` when the write applied, `false` when another writer got there +/// first and the caller's decision was made against a stale read. +/// +/// This is a compare-and-swap because the caller does a read-modify-write with no +/// transaction around it: `get_alert_state` then decide then write. Two runs of +/// the SAME check can complete close together — a run takes longer than the +/// interval whenever the target is slow, which is exactly when it is failing — and +/// both would read `consecutive_failures = 2`, both write 3, and both conclude +/// they were outside the cooldown. The streak would undercount and the +/// notification would double. +/// +/// The three columns are guarded, not just the counter: a lost `alerting` +/// transition is what produces a recovery for an incident nobody was told about. +/// +/// Compare `synthetics_runs::increment_jobs_done`, which is a single atomic +/// `jobs_done = jobs_done + 1`. That shape is not available here because the new +/// value depends on a policy decision, not on arithmetic over the old one. +pub async fn update_alert_state_if( + conn: &C, + id: &str, + expected: AlertState, + state: AlertState, +) -> Result { + let res = Entity::update_many() + .col_expr( + Column::ConsecutiveFailures, + Expr::value(state.consecutive_failures), + ) + .col_expr(Column::LastAlertAt, Expr::value(state.last_alert_at)) + .col_expr(Column::Alerting, Expr::value(state.alerting)) + .col_expr( + Column::DegradedNotifiedAt, + Expr::value(state.degraded_notified_at), + ) + .filter(Column::Id.eq(id)) + .filter(Column::ConsecutiveFailures.eq(expected.consecutive_failures)) + .filter(Column::LastAlertAt.eq(expected.last_alert_at)) + .filter(Column::Alerting.eq(expected.alerting)) + .filter(Column::DegradedNotifiedAt.eq(expected.degraded_notified_at)) + .exec(conn) + .await?; + Ok(res.rows_affected > 0) +} + // ── Private helpers ─────────────────────────────────────────────────────────── async fn get_model( @@ -616,6 +704,10 @@ mod tests { next_run_at: 0, last_triggered_at: 0, last_check_status: 0, + consecutive_failures: 0, + last_alert_at: 0, + alerting: false, + degraded_notified_at: 0, owner: None, created_at: 1750000000000000, updated_at: 1750000000000000, diff --git a/web/src/components/synthetics/CreateBrowserTest.schema.spec.ts b/web/src/components/synthetics/CreateBrowserTest.schema.spec.ts new file mode 100644 index 0000000000..ac76e52351 --- /dev/null +++ b/web/src/components/synthetics/CreateBrowserTest.schema.spec.ts @@ -0,0 +1,260 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; +import { makeBrowserCheckSaveSchema } from "./CreateBrowserTest.schema"; + +const t = (key: string) => key; + +// Most journey fixtures below exercise the target and first-step rules, not names, +// so a name is supplied by default — step name is required (D10) and every fixture +// would otherwise trip that rule for reasons unrelated to what it is testing. A +// case that IS about names passes an explicit `name`, which wins over the default. +function form(journey: unknown[]) { + return { + name: "check", + url: "https://app.test", + locations: ["us-east"], + journey: journey.map((step) => ({ name: "Step", ...(step as Record) })), + }; +} + +function issuePaths(result: { success: boolean; error?: { issues: { path: PropertyKey[] }[] } }) { + return result.success ? [] : (result.error?.issues ?? []).map((i) => i.path.join(".")); +} + +describe("makeBrowserCheckSaveSchema journey validation", () => { + const schema = makeBrowserCheckSaveSchema(t); + + it("should accept a v1 journey whose steps carry selectors", () => { + const result = schema.safeParse( + form([ + { id: "1", action: "navigate", value: "https://app.test" }, + { id: "2", action: "click", selector: "#login" }, + ]), + ); + + expect(result.success).toBe(true); + }); + + // Regression: a check saved as steps_version 2 comes back with no `selector` + // at all — the element lives in `locator`. Validating on `selector` alone made + // every v2 check unsaveable the moment it was reopened for editing. + it("should accept a v2 journey whose steps identify the element by locator", () => { + const result = schema.safeParse( + form([ + { id: "1", action: "navigate", value: "https://app.test" }, + { + id: "2", + action: "click", + name: 'Click on internal:testid=[data-test="login-as-internal-user"]', + locator: { + candidates: [ + { + kind: "test_attribute", + value: 'internal:testid=[data-test="login-as-internal-user"]', + }, + ], + user_override: null, + }, + }, + ]), + ); + + expect(issuePaths(result)).toEqual([]); + expect(result.success).toBe(true); + }); + + it("should accept a v2 step whose only target is a pinned override", () => { + const result = schema.safeParse( + form([ + { id: "1", action: "navigate", value: "https://app.test" }, + { + id: "2", + action: "click", + locator: { candidates: [], user_override: { kind: "css", value: "#login" } }, + }, + ]), + ); + + expect(result.success).toBe(true); + }); + + it("should accept a page-level assertion, which targets no element", () => { + const result = schema.safeParse( + form([ + { id: "1", action: "navigate", value: "https://app.test" }, + { id: "2", action: "assert", assertion: { kind: "url_matches", expected: "/home" } }, + ]), + ); + + expect(result.success).toBe(true); + }); + + it("should still reject a step that identifies no element at all", () => { + const result = schema.safeParse( + form([ + { id: "1", action: "navigate", value: "https://app.test" }, + { id: "2", action: "click" }, + ]), + ); + + expect(result.success).toBe(false); + expect(issuePaths(result)).toContain("journey.1.selector"); + }); + + it("should still require the first step to navigate", () => { + const result = schema.safeParse(form([{ id: "1", action: "click", selector: "#login" }])); + + expect(result.success).toBe(false); + expect(issuePaths(result)).toContain("journey.0.action"); + }); +}); + +// The step name is the string a failed run displays, and it was optional. Enforced +// by min(1).trim() in this schema rather than a second validation path, matching +// the monitor-level name rule. Recorded steps arrive already named from the +// recorder, so the friction lands on hand-added steps (D10). +describe("makeBrowserCheckSaveSchema step name", () => { + const schema = makeBrowserCheckSaveSchema(t); + const pin = { candidates: [], user_override: { kind: "css", value: "#go" } }; + + it("should reject a step with a blank name", () => { + const result = schema.safeParse( + form([ + { id: "1", action: "navigate", value: "https://app.test" }, + { id: "2", action: "click", name: "", locator: pin }, + ]), + ); + + expect(result.success).toBe(false); + expect(issuePaths(result)).toContain("journey.1.name"); + }); + + it("should reject a whitespace-only name", () => { + const result = schema.safeParse( + form([ + { id: "1", action: "navigate", value: "https://app.test" }, + { id: "2", action: "click", name: " ", locator: pin }, + ]), + ); + + expect(result.success).toBe(false); + expect(issuePaths(result)).toContain("journey.1.name"); + }); + + it("should report the message the editor binds to the field", () => { + const result = schema.safeParse( + form([{ id: "1", action: "navigate", value: "https://app.test", name: "" }]), + ); + + const issue = (result as any).error.issues.find( + (i: { path: PropertyKey[] }) => i.path.join(".") === "journey.0.name", + ); + expect(issue.message).toBe("synthetics.validation.stepNameRequired"); + }); + + it("should accept a recorded journey, which arrives already named", () => { + const result = schema.safeParse( + form([ + { id: "1", action: "navigate", value: "https://app.test", name: "Open app" }, + { + id: "2", + action: "click", + name: 'Click on [data-test="sign-in"]', + locator: { candidates: [{ kind: "test_attribute", value: '[data-test="sign-in"]' }] }, + }, + ]), + ); + + expect(result.success).toBe(true); + }); +}); + +// Field-level step rules live in the schema, not in validateJourneySteps, so +// there is one enforcement path and every failure carries a field path the editor +// can bind an inline error to. Before this, validation was save-time and +// toast-only and covered two rules (SE-3). +describe("makeBrowserCheckSaveSchema field-level step rules", () => { + const schema = makeBrowserCheckSaveSchema(t); + const pin = { candidates: [], user_override: { kind: "css", value: "#go" } }; + const opened = { id: "1", action: "navigate", value: "https://app.test" }; + + it("should reject a navigate step whose URL is not http(s)", () => { + const result = schema.safeParse(form([{ id: "1", action: "navigate", value: "app.test" }])); + + expect(result.success).toBe(false); + expect(issuePaths(result)).toContain("journey.0.value"); + }); + + it("should reject a navigate step with no URL at all", () => { + const result = schema.safeParse(form([{ id: "1", action: "navigate" }])); + + expect(result.success).toBe(false); + expect(issuePaths(result)).toContain("journey.0.value"); + }); + + it("should accept a navigate step with a valid URL", () => { + const result = schema.safeParse(form([opened])); + + expect(result.success).toBe(true); + }); + + // A `type` step with no text types nothing and the run still passes. + it("should reject a type step with no text", () => { + const result = schema.safeParse( + form([opened, { id: "2", action: "type", value: "", locator: pin }]), + ); + + expect(result.success).toBe(false); + expect(issuePaths(result)).toContain("journey.1.value"); + }); + + it("should accept a type step that has text", () => { + const result = schema.safeParse( + form([opened, { id: "2", action: "type", value: "hunter2", locator: pin }]), + ); + + expect(result.success).toBe(true); + }); + + it("should reject an assertion kind that needs an expected value but has none", () => { + const result = schema.safeParse( + form([ + opened, + { + id: "2", + action: "assert", + locator: pin, + assertion: { kind: "element_text", expected: "" }, + }, + ]), + ); + + expect(result.success).toBe(false); + expect(issuePaths(result)).toContain("journey.1.assertion.expected"); + }); + + it("should accept a visibility assertion, which needs no expected value", () => { + const result = schema.safeParse( + form([ + opened, + { id: "2", action: "assert", locator: pin, assertion: { kind: "element_visible" } }, + ]), + ); + + expect(result.success).toBe(true); + }); +}); diff --git a/web/src/components/synthetics/CreateBrowserTest.schema.ts b/web/src/components/synthetics/CreateBrowserTest.schema.ts index 574f012197..fa2158f433 100644 --- a/web/src/components/synthetics/CreateBrowserTest.schema.ts +++ b/web/src/components/synthetics/CreateBrowserTest.schema.ts @@ -8,6 +8,16 @@ // • url — required + valid HTTP(S) URL. import { z } from "zod"; +import { stepIsMissingTarget } from "@/utils/synthetics/stepTarget"; +import { assertionNeedsExpected } from "@/constants/synthetics"; +import type { AssertionKind } from "@/types/synthetics"; + +/** The version-2 locator bundle, as it sits on an editor step. */ +const locatorCandidateSchema = z.object({ kind: z.string(), value: z.string() }); +const locatorSchema = z.object({ + candidates: z.array(locatorCandidateSchema).nullish(), + user_override: locatorCandidateSchema.nullish(), +}); export const makeBrowserCheckGateSchema = (t: (_key: string) => string) => z.object({ @@ -59,12 +69,26 @@ export const makeBrowserCheckSaveSchema = (t: (_key: string) => string) => z.object({ id: z.string(), action: z.string(), - name: z.string().optional(), + // The string a failed run displays, so it cannot be blank. Recorded + // steps arrive named from the recorder, which is why requiring it + // lands on hand-added steps rather than on every recording. + name: z.string().trim().min(1, t("synthetics.validation.stepNameRequired")), selector: z.string().optional(), selectorType: z.string().optional(), value: z.string().optional(), timeout: z.number().optional(), code: z.string().optional(), + // A version-2 step names its element here and carries no `selector`. + // Declared explicitly because z.object strips what it does not + // declare — leaving it out made every v2 step look target-less to + // the refinement below. + locator: locatorSchema.optional(), + // `expected` is declared, not just tolerated by `.loose()`, so the + // refinement below can read it in a typed way. + assertion: z + .object({ kind: z.string().optional(), expected: z.string().optional() }) + .loose() + .optional(), }), ) .optional() @@ -81,14 +105,9 @@ export const makeBrowserCheckSaveSchema = (t: (_key: string) => string) => }); } - // Validate selectors on steps that require them - const SELECTOR_ACTIONS = ["click", "type", "select", "hover", "assert"] as const; + // Every element-acting step must name its element, by either channel. for (let i = 0; i < val.journey.length; i++) { - const step = val.journey[i]; - if ( - SELECTOR_ACTIONS.includes(step.action as any) && - (!step.selector || step.selector.trim() === "") - ) { + if (stepIsMissingTarget(val.journey[i])) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["journey", i, "selector"], @@ -96,6 +115,43 @@ export const makeBrowserCheckSaveSchema = (t: (_key: string) => string) => }); } } + + // Field-level step rules. These live here rather than in + // validateJourneySteps so there is one enforcement path, and so every + // failure carries a field path the editor can bind an inline error to. + for (let i = 0; i < val.journey.length; i++) { + const step = val.journey[i]; + + if (step.action === "navigate" && !/^https?:\/\/\S+$/i.test(step.value ?? "")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["journey", i, "value"], + message: t("synthetics.validation.urlInvalid"), + }); + } + + // A `type` step with no text types nothing and the run still passes. + if (step.action === "type" && !(step.value ?? "").trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["journey", i, "value"], + message: t("synthetics.validation.typeTextRequired"), + }); + } + + if ( + step.action === "assert" && + step.assertion?.kind && + assertionNeedsExpected(step.assertion.kind as AssertionKind) && + !(step.assertion.expected ?? "").trim() + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["journey", i, "assertion", "expected"], + message: t("synthetics.validation.expectedRequired"), + }); + } + } }); export type BrowserCheckSaveForm = z.infer>; diff --git a/web/src/components/synthetics/StepEvidence.spec.ts b/web/src/components/synthetics/StepEvidence.spec.ts new file mode 100644 index 0000000000..0e8a399916 --- /dev/null +++ b/web/src/components/synthetics/StepEvidence.spec.ts @@ -0,0 +1,199 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import StepEvidence from "./StepEvidence.vue"; +import type { FailureDetail } from "@/composables/synthetics/syntheticResultsSchema"; +import en from "@/locales/languages/en-US.json"; + +const i18n = createI18n({ legacy: false, locale: "en-US", messages: { "en-US": en } }); + +function detail(over: Partial = {}): FailureDetail { + return { + stepId: "s2", + stepName: "Profile visible", + stepIndex: 2, + error: "locator.waitFor: Timeout 60000ms exceeded", + candidatesTried: [], + settleSignals: [], + settleMs: null, + observedDurationMs: null, + screenshotKey: null, + traceKey: null, + ...over, + }; +} + +function render(d: FailureDetail) { + return mount(StepEvidence, { props: { detail: d }, global: { plugins: [i18n] } }); +} + +describe("StepEvidence — locator resolution (P5.4 item 3)", () => { + it("says the element was absent when no candidate matched", () => { + const w = render( + detail({ + candidatesTried: [ + { kind: "test_attribute", value: "[data-test=x]", outcome: "not_found" }, + { kind: "role", value: "internal:role=button", outcome: "not_found" }, + ], + }), + ); + expect(w.find('[data-test="synthetics-run-detail-locator-none-matched"]').exists()).toBe(true); + expect(w.find('[data-test="synthetics-run-detail-locator-healed"]').exists()).toBe(false); + }); + + it("says the markup moved when a fallback matched instead of the primary", () => { + // This is the mechanical answer to "locator rot?" — and a different + // diagnosis from "not found", which is why they are separate messages. + const w = render( + detail({ + candidatesTried: [ + { kind: "test_attribute", value: "[data-test=x]", outcome: "not_found" }, + { kind: "role", value: "internal:role=button", outcome: "matched" }, + ], + }), + ); + expect(w.find('[data-test="synthetics-run-detail-locator-healed"]').exists()).toBe(true); + }); + + it("does not claim healing when the primary itself matched", () => { + const w = render( + detail({ + candidatesTried: [{ kind: "test_attribute", value: "[data-test=x]", outcome: "matched" }], + }), + ); + expect(w.find('[data-test="synthetics-run-detail-locator-healed"]').exists()).toBe(false); + }); +}); + +describe("StepEvidence — settle signals (P5.4 item 4)", () => { + it("flags a stale signal as the likely real cause", () => { + const w = render( + detail({ + settleSignals: [ + { + kind: "response", + signal: "response matching **/auth/login", + status: "stale", + required: false, + waitedMs: 30000, + }, + ], + }), + ); + expect(w.find('[data-test="synthetics-run-detail-settle-stale-note"]').exists()).toBe(true); + expect(w.text()).toContain("**/auth/login"); + }); + + it("stays quiet when every signal fired", () => { + const w = render( + detail({ + settleSignals: [ + { + kind: "navigation", + signal: "navigation to **/web/**", + status: "fired", + required: false, + waitedMs: 800, + }, + ], + }), + ); + expect(w.find('[data-test="synthetics-run-detail-settle-stale-note"]').exists()).toBe(false); + }); +}); + +describe("StepEvidence — settle timing (P5.4 item 5)", () => { + it("shows today against recording, and calls out a large regression", () => { + // Slow-but-healthy separates from broken on this line alone. + const w = render(detail({ settleMs: 41000, observedDurationMs: 2300 })); + expect(w.text()).toContain("41.0s"); + expect(w.text()).toContain("2.3s"); + expect(w.find('[data-test="synthetics-run-detail-settle-slower"]').exists()).toBe(true); + }); + + it("does not cry regression when timing is comparable", () => { + const w = render(detail({ settleMs: 2400, observedDurationMs: 2300 })); + expect(w.find('[data-test="synthetics-run-detail-settle-slower"]').exists()).toBe(false); + }); + + it("renders nothing for a record that carries no evidence", () => { + // Backwards compatibility: runs predating the probe change must degrade, + // not break the view. + const w = render(detail()); + expect(w.find('[data-test="synthetics-run-detail-locator-resolution"]').exists()).toBe(false); + expect(w.find('[data-test="synthetics-run-detail-settle-signals"]').exists()).toBe(false); + expect(w.find('[data-test="synthetics-run-detail-settle-timing"]').exists()).toBe(false); + }); +}); + +describe("StepEvidence — what the application did (Phase 6)", () => { + const appEvidence = { + stepId: "s2", + consoleErrors: 1, + pageErrors: 1, + requestsFailed: 0, + responsesNon2xx: 3, + worstResponses: [{ method: "POST", url: "https://x/auth/login", status: 503, count: 3 }], + firstConsoleErrors: ["[auth] sign-in failed: 503 Service Unavailable"], + }; + + function renderWith(over: Record = {}) { + return mount(StepEvidence, { + props: { detail: detail(), evidence: appEvidence, ...over }, + global: { plugins: [i18n] }, + }); + } + + it("shows the failing response — the thing no field distinguishes today", () => { + const w = renderWith(); + expect(w.find('[data-test="synthetics-run-detail-app-evidence"]').exists()).toBe(true); + expect(w.text()).toContain("503"); + expect(w.text()).toContain("/auth/login"); + }); + + it("collapses repeats into a count rather than listing them", () => { + expect(renderWith().text()).toContain("x3"); + }); + + it("shows the application's own console error", () => { + expect(renderWith().text()).toContain("sign-in failed"); + }); + + it("stays hidden when the step had nothing to report", () => { + // A healthy step contributes no rows — sparse by construction, not padded. + const w = renderWith({ + evidence: { + ...appEvidence, + consoleErrors: 0, + pageErrors: 0, + requestsFailed: 0, + responsesNon2xx: 0, + worstResponses: [], + firstConsoleErrors: [], + }, + }); + expect(w.find('[data-test="synthetics-run-detail-app-evidence"]').exists()).toBe(false); + }); + + it("stays hidden on a record that predates evidence capture", () => { + const w = renderWith({ evidence: null }); + expect(w.find('[data-test="synthetics-run-detail-app-evidence"]').exists()).toBe(false); + }); + + it("reports truncation rather than letting it pass silently", () => { + // X-8.2 — a capped buffer that drops events without saying so reads as + // "nothing else happened". + const w = renderWith({ truncated: true }); + expect(w.find('[data-test="synthetics-run-detail-evidence-truncated"]').exists()).toBe(true); + }); + + it("says nothing about truncation when nothing was dropped", () => { + expect( + renderWith({ truncated: false }) + .find('[data-test="synthetics-run-detail-evidence-truncated"]') + .exists(), + ).toBe(false); + }); +}); diff --git a/web/src/components/synthetics/StepEvidence.vue b/web/src/components/synthetics/StepEvidence.vue new file mode 100644 index 0000000000..4519b41125 --- /dev/null +++ b/web/src/components/synthetics/StepEvidence.vue @@ -0,0 +1,248 @@ + + + diff --git a/web/src/components/synthetics/journey/BrowserJourney.spec.ts b/web/src/components/synthetics/journey/BrowserJourney.spec.ts index 5c63a8ecb5..3df1af6194 100644 --- a/web/src/components/synthetics/journey/BrowserJourney.spec.ts +++ b/web/src/components/synthetics/journey/BrowserJourney.spec.ts @@ -211,41 +211,82 @@ describe("BrowserJourney recording", () => { expect(wrapper.find('[data-test="synthetics-journey-stop-btn"]').exists()).toBe(true); }); - it("should sync selector edit into step.wire when handleStepUpdate fires", async () => { - const wire = { - id: "w1", - action: "click", - selector: "#old", - name: "Old Click", - selector_type: "css", - }; + // The v1 counterpart of this test — editing the bare `selector` input and + // asserting it reached `wire.selector` — was deleted with the v1 authoring path. + // The mechanism it guarded (an edit must land in `wire`, or journeyToWireSteps + // discards it at replay time) is covered by the navigate-URL test below. + + // Regression: this view used to inline its own, thinner copy of the step + // editor. It rendered no locator bundle, no settle block, no assertion editor + // and no optional/always-run checkboxes, so which fields an author could see + // depended on whether they were recording or editing a saved check. + it("should render the full step editor, not a reduced copy of it", async () => { const step = { id: "s1", action: "click", - name: "Old Click", - selector: "#old", - timeout: 30000, + name: "Sign in", code: "", - wire, + locator: { candidates: [{ kind: "test_attribute", value: '[data-test="sign-in"]' }] }, + settle: { + navigation: { url_pattern: "**/web/**" }, + responses: [{ url_pattern: "**/auth/login", method: "POST", required: false }], + budget_ms: 5000, + }, + wire: { id: "w1", action: "click" }, }; wrapper = mount(BrowserJourney, { props: { modelValue: [step] }, - global: { - stubs: { ...STUBS, JourneySteps: JourneyStepsStubWithExpansion }, - }, + global: { stubs: { ...STUBS, JourneySteps: JourneyStepsStubWithExpansion } }, }); - // The expansion slot renders an OInput for the selector with - // data-test="synthetics-journey-step-selector-input". Update its value. - const selectorInput = wrapper.find('[data-test="synthetics-journey-step-selector-input"]'); - await selectorInput.setValue("#new"); + // The editor keeps its tuning fields behind one `Advanced` collapsible (SE-5), + // and OCollapsible unmounts collapsed content — so open it before asserting. The + // point of this test is that no field is MISSING, not that all of them are + // visible at once. + const advanced = wrapper.find('[data-test="synthetics-journey-step-group-advanced"] button'); + expect(advanced.exists()).toBe(true); + if (advanced.attributes("data-state") !== "open") await advanced.trigger("click"); - const emitted = wrapper.emitted("update:modelValue"); - expect(emitted).toBeTruthy(); - const updatedSteps = emitted![emitted!.length - 1][0] as any[]; - expect(updatedSteps[0].selector).toBe("#new"); - expect(updatedSteps[0].wire.selector).toBe("#new"); + for (const dt of [ + "synthetics-journey-step-editor", + "synthetics-journey-step-group-does", + "synthetics-journey-step-group-advanced", + "synthetics-journey-step-locator", + "synthetics-journey-step-settle", + "synthetics-journey-step-settle-required-0", + "synthetics-journey-step-settle-budget-input", + "synthetics-journey-step-optional-checkbox", + "synthetics-journey-step-always-run-checkbox", + "synthetics-journey-step-timeout-input", + ]) { + expect(wrapper.find(`[data-test="${dt}"]`).exists(), dt).toBe(true); + } + }); + + it("should route an edited navigate URL to wire.url so replay uses it", async () => { + const step = { + id: "s1", + action: "navigate", + name: "Open page", + value: "https://old.test", + code: "", + wire: { id: "w1", action: "navigate", url: "https://old.test" }, + }; + + wrapper = mount(BrowserJourney, { + props: { modelValue: [step] }, + global: { stubs: { ...STUBS, JourneySteps: JourneyStepsStubWithExpansion } }, + }); + + await wrapper + .find('[data-test="synthetics-journey-step-value-input"]') + .setValue("https://new.test"); + + const emitted = wrapper.emitted("update:modelValue")!; + const next = emitted[emitted.length - 1][0] as any[]; + expect(next[0].value).toBe("https://new.test"); + expect(next[0].wire.url).toBe("https://new.test"); }); it("should emit clear-results when modelValue becomes empty", async () => { @@ -260,3 +301,242 @@ describe("BrowserJourney recording", () => { expect(wrapper.emitted("clear-results")).toBeTruthy(); }); }); + +describe("BrowserJourney step validation", () => { + let wrapper: VueWrapper; + + afterEach(() => { + wrapper?.unmount(); + vi.restoreAllMocks(); + }); + + function validate(modelValue: unknown[]): boolean { + wrapper = mountJourney({ modelValue }); + return (wrapper.vm as any).validateStepSelectors(); + } + + it("should pass a v1 journey whose steps carry selectors", () => { + expect( + validate([ + { id: "1", action: "navigate", value: "https://app.test" }, + { id: "2", action: "click", selector: "#login" }, + ]), + ).toBe(true); + }); + + // Regression: a v2 step identifies its element with a locator bundle and has + // no `selector` at all. Requiring `selector` blocked Save & Continue on every + // recorded journey the moment it was reopened for editing. + it("should pass a v2 step that identifies its element by locator", () => { + expect( + validate([ + { id: "1", action: "navigate", value: "https://app.test" }, + { + id: "2", + action: "click", + locator: { + candidates: [{ kind: "test_attribute", value: 'internal:testid=[data-test="login"]' }], + user_override: null, + }, + }, + ]), + ).toBe(true); + }); + + it("should pass a v2 step whose only target is a pinned override", () => { + expect( + validate([ + { id: "1", action: "navigate", value: "https://app.test" }, + { + id: "2", + action: "click", + locator: { candidates: [], user_override: { kind: "css", value: "#login" } }, + }, + ]), + ).toBe(true); + }); + + it("should pass a page-level assertion, which targets no element", () => { + expect( + validate([ + { id: "1", action: "navigate", value: "https://app.test" }, + { id: "2", action: "assert", assertion: { kind: "url_matches", expected: "/home" } }, + ]), + ).toBe(true); + }); + + it("should fail a step that identifies no element at all", () => { + expect( + validate([ + { id: "1", action: "navigate", value: "https://app.test" }, + { id: "2", action: "click" }, + ]), + ).toBe(false); + }); + + it("should fail when the first step does not navigate", () => { + expect(validate([{ id: "1", action: "click", selector: "#login" }])).toBe(false); + }); +}); + +// ── Step creation ───────────────────────────────────────────────────────── +// A created step must carry no timeout. Absence means "use the runner's +// per-category default" (spec P1.1.1-P1.1.3); a stamped value freezes at 30000 +// while recorded steps follow the default, and fires the below-default warning +// as soon as the author picks navigate or assert (default 60000) without ever +// having touched the field. +describe("BrowserJourney step creation", () => { + let wrapper: VueWrapper; + + afterEach(() => { + wrapper?.unmount(); + vi.restoreAllMocks(); + }); + + function lastEmittedSteps(w: VueWrapper): any[] { + const emitted = w.emitted("update:modelValue"); + expect(emitted).toBeTruthy(); + return emitted![emitted!.length - 1][0] as any[]; + } + + it("should create a step with no timeout via Add Step", async () => { + wrapper = mountJourney({ modelValue: [] }); + await wrapper.find('[data-test="synthetics-journey-add-step-btn"]').trigger("click"); + + const steps = lastEmittedSteps(wrapper); + expect(steps).toHaveLength(1); + expect(steps[0].timeout).toBeUndefined(); + }); + + it("should create a step with no timeout via insert below", async () => { + const existing = { + id: "s1", + action: "navigate", + name: "Open app", + value: "https://app.test", + code: "", + }; + wrapper = mountJourney({ modelValue: [existing] }); + + // Drive the row action through the event contract JourneySteps emits, + // rather than reaching into the component's internals. + wrapper.findComponent(JourneyStepsStub).vm.$emit("insert-below", existing); + await flushPromises(); + + const steps = lastEmittedSteps(wrapper); + expect(steps).toHaveLength(2); + expect(steps[1].timeout).toBeUndefined(); + }); +}); + +// A created step is a version-2 step: its identity is the locator bundle, never a +// bare `selector`. Seeding the bundle empty is what makes the editor render the +// Locator block from the start, and what keeps isV2Journey true once the author +// supplies a locator (SE-18). +describe("BrowserJourney step creation is version 2", () => { + let wrapper: VueWrapper; + + afterEach(() => { + wrapper?.unmount(); + vi.restoreAllMocks(); + }); + + it("should seed an empty locator bundle and write no v1 selector fields", async () => { + wrapper = mountJourney({ modelValue: [] }); + await wrapper.find('[data-test="synthetics-journey-add-step-btn"]').trigger("click"); + + const emitted = wrapper.emitted("update:modelValue")!; + const steps = emitted[emitted.length - 1][0] as any[]; + expect(steps[0].locator).toEqual({ candidates: [], user_override: null }); + expect(steps[0].selector).toBeUndefined(); + expect(steps[0].selectorType).toBeUndefined(); + }); +}); + +// Phase 5 / SE-4. The evidence existed and was discarded: JourneySteps declares a +// getReplayResult prop "for error cards" and never rendered one, and BrowserJourney +// never passed it. A failed replay showed a red dot and a one-line banner only. +describe("BrowserJourney per-step failure evidence", () => { + let wrapper: VueWrapper; + + afterEach(() => { + wrapper?.unmount(); + vi.restoreAllMocks(); + }); + + const journey = [ + { id: "s1", action: "navigate", name: "Open app", value: "https://app.test", code: "" }, + { + id: "s2", + action: "click", + name: "Sign in", + code: "", + locator: { candidates: [{ kind: "css", value: "#go" }] }, + }, + ]; + + function mountFailed() { + const stepResults = new Map([ + [ + "s2", + { + stepId: "s2", + stepName: "Sign in", + passed: false, + durationMs: 30000, + error: "Timeout 30000ms exceeded.", + fidelity: { level: "reduced", notes: ["primary locator only"] }, + }, + ], + ]); + return mount(BrowserJourney, { + props: { modelValue: journey, replayPhase: "failed", stepResults }, + global: { stubs: { ...STUBS, JourneySteps: JourneyStepsStubWithExpansion } }, + }) as VueWrapper; + } + + it("should render the error card against the step that failed", () => { + wrapper = mountFailed(); + const cards = wrapper.findAll('[data-test="synthetics-journey-step-error-card"]'); + expect(cards.length).toBe(1); + expect(cards[0].text()).toContain("Timeout 30000ms exceeded"); + }); + + it("should surface the player's fidelity notes (X-8.2)", () => { + wrapper = mountFailed(); + expect(wrapper.find('[data-test="synthetics-journey-step-fidelity"]').text()).toContain( + "primary locator only", + ); + }); + + it("should not render a card for a step that passed", () => { + const stepResults = new Map([ + ["s1", { stepId: "s1", stepName: "Open app", passed: true, durationMs: 900 }], + ]); + wrapper = mount(BrowserJourney, { + props: { modelValue: journey, replayPhase: "passed", stepResults }, + global: { stubs: { ...STUBS, JourneySteps: JourneyStepsStubWithExpansion } }, + }) as VueWrapper; + expect(wrapper.find('[data-test="synthetics-journey-step-error-card"]').exists()).toBe(false); + }); + + it("should not render a card when no replay has run", () => { + wrapper = mount(BrowserJourney, { + props: { modelValue: journey }, + global: { stubs: { ...STUBS, JourneyStepsStubWithExpansion } }, + }) as VueWrapper; + expect(wrapper.find('[data-test="synthetics-journey-step-error-card"]').exists()).toBe(false); + }); + + // The old button emitted a full journey replay from inside a per-step card. A + // single step is not independently runnable, so the honest unit is the prefix. + it("should emit replay-up-to with the failed step's position", async () => { + wrapper = mountFailed(); + await wrapper.find('[data-test="synthetics-journey-error-retry-btn"]').trigger("click"); + // OButtonStub both $emits "click" and lets the native event through (it declares + // no `emits`), so one press registers twice. The payload is the contract here. + const emitted = wrapper.emitted("replay-up-to")!; + expect(emitted.length).toBeGreaterThan(0); + for (const call of emitted) expect(call).toEqual([2]); + }); +}); diff --git a/web/src/components/synthetics/journey/BrowserJourney.vue b/web/src/components/synthetics/journey/BrowserJourney.vue index dc690f9cb3..228a8eb5ca 100644 --- a/web/src/components/synthetics/journey/BrowserJourney.vue +++ b/web/src/components/synthetics/journey/BrowserJourney.vue @@ -2,35 +2,35 @@ // Copyright 2026 OpenObserve Inc. import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; -import type { BrowserStep, ReplayPhase, StepReplayResult, WireStep } from "@/types/synthetics"; +import type { BrowserStep, ReplayPhase, StepReplayResult } from "@/types/synthetics"; import type { StepDotState } from "./JourneySteps.vue"; import useSyntheticsRecorder from "@/composables/useSyntheticsRecorder"; import { getUUIDv7 } from "@/utils/zincutils"; import OButton from "@/lib/core/Button/OButton.vue"; import OIcon from "@/lib/core/Icon/OIcon.vue"; import OInput from "@/lib/forms/Input/OInput.vue"; -import OSelect from "@/lib/forms/Select/OSelect.vue"; import OBadge from "@/lib/core/Badge/OBadge.vue"; import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue"; -import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue"; import ConfirmDialog from "@/components/ConfirmDialog.vue"; import { toast } from "@/lib/feedback/Toast/useToast"; import JourneySteps from "./JourneySteps.vue"; -import { - ACTION_LABELS, - SELECTOR_ACTIONS as SELECTOR_ACTIONS_CONST, - VALUE_ACTIONS as VALUE_ACTIONS_CONST, - VALUE_LABELS, - SELECTOR_TYPE_OPTIONS, - actionOptions, - VALUE_WIDTH_MAP, - VALUE_TOOLTIP_MAP, -} from "@/constants/synthetics"; +import UpgradeJourneyBanner from "./UpgradeJourneyBanner.vue"; +import ZeroAssertionNotice from "./ZeroAssertionNotice.vue"; +import TestIdMisconfiguredNotice from "./TestIdMisconfiguredNotice.vue"; +import { DEFAULT_TEST_ID_ATTR } from "@/constants/synthetics"; +import BrowserJourneyStepEditor from "./BrowserJourneyStepEditor.vue"; +import BrowserJourneyStepError from "./BrowserJourneyStepError.vue"; +import { stepIsMissingTarget } from "@/utils/synthetics/stepTarget"; const props = defineProps<{ modelValue: BrowserStep[]; readonly?: boolean; startUrl?: string; // URL shown in the recording banner + /** + * DOM attribute the recorder selects on, from the monitor's config. + * Absent falls back to DEFAULT_TEST_ID_ATTR — see useSyntheticsRecorder. + */ + testIdAttr?: string; extensionReady?: boolean; // when false, Record button triggers need-extension-setup autoRecord?: boolean; // if true, start recording immediately on mount /** Owned by the parent (CreateBrowserTest). */ @@ -48,6 +48,17 @@ const emit = defineEmits<{ "need-extension-setup": []; "clear-results": []; replay: []; + /** + * Replay only the first `upTo` steps (1-based, inclusive). + * + * A single step is not independently runnable — journey state is cumulative and + * the extension starts each replay from the target URL, so step 5 alone would run + * against a fresh page with none of the preceding state. A PREFIX is runnable, and + * `replay()` already accepts an arbitrary WireStep[], so this needs no extension + * change. The old error-card button emitted a full `replay` while sitting inside a + * per-step card, promising something it did not do (SE-4). + */ + "replay-up-to": [upTo: number]; "stop-replay": []; "auto-record-consumed": []; "selection-changed": [{ count: number; isRecording: boolean }]; @@ -106,6 +117,39 @@ const failedStepResult = computed(() => { return props.stepResults?.get(step.id); }); +/** + * The failed replay result for a given row, if any. + * + * Only while a replay is in a terminal/active state — a stale result from a previous + * run must not keep a card on screen after the journey is edited. + */ +function failedResultFor(row: BrowserStep): StepReplayResult | undefined { + if (!isReplayActive.value) return undefined; + const r = props.stepResults?.get(row.id); + return r && !r.passed ? r : undefined; +} + +function stepNumberOf(row: BrowserStep): number { + return props.modelValue.findIndex((s) => s.id === row.id) + 1; +} + +/** + * A failed step's evidence lives in the row's expansion, so open it automatically — + * the same thing validateJourneySteps does for validation errors. Without this a + * tester has to guess which row to expand to find out what happened. + */ +watch( + () => (props.replayPhase === "failed" ? firstFailedIndex.value : -1), + (idx) => { + if (idx < 0) return; + const step = props.modelValue[idx]; + if (step && !expandedStepIds.value.includes(step.id)) { + expandedStepIds.value = [...expandedStepIds.value, step.id]; + } + }, + { immediate: true }, +); + /** Derive the status dot state for a step based on replay results. */ function stepDotState(stepId: string): StepDotState | undefined { if (!isReplayActive.value || !props.replayPhase) return undefined; @@ -204,20 +248,53 @@ watch([selectedCount, isRecording], ([count, recording]) => { const selectorErrors = ref>(new Set()); const firstStepError = ref(false); +/** + * Field errors for the expanded editor, keyed by step id then field name. + * + * Populated from the zod issue paths so one enforcement path produces both the + * save block and the inline messages. Keying by step **id** rather than index + * means a reorder or a delete cannot leave an error pointing at the wrong row. + */ +const stepFieldErrors = ref>>(new Map()); + +/** Record zod issues whose path points at a journey step field. */ +function setStepFieldErrors(issues: { path: PropertyKey[]; message: string }[]) { + const next = new Map>(); + for (const issue of issues) { + if (issue.path[0] !== "journey" || typeof issue.path[1] !== "number") continue; + const step = props.modelValue[issue.path[1]]; + if (!step) continue; + // `journey.3.assertion.expected` → "assertion.expected"; a bare + // `journey.3` (a whole-step issue) is attributed to the action field. + const field = issue.path.slice(2).join(".") || "action"; + next.set(step.id, { ...(next.get(step.id) ?? {}), [field]: issue.message }); + } + stepFieldErrors.value = next; +} + +function fieldError(stepId: string, field: string): string { + return stepFieldErrors.value.get(stepId)?.[field] ?? ""; +} + +function clearFieldError(stepId: string, field: string) { + const current = stepFieldErrors.value.get(stepId); + if (!current?.[field]) return; + const { [field]: _dropped, ...rest } = current; + const next = new Map(stepFieldErrors.value); + next.set(stepId, rest); + stepFieldErrors.value = next; +} + function validateJourneySteps(): boolean { // 1. First step must be "navigate" const first = props.modelValue[0]; firstStepError.value = first ? first.action !== "navigate" : false; - // 2. Selector-requiring steps must have a selector + // 2. Element-acting steps must name their element — by a v1 `selector` or a + // v2 locator bundle. See stepIsMissingTarget. const selErrs = new Set(); for (const step of props.modelValue) { - if ( - SELECTOR_ACTIONS_CONST.includes(step.action as any) && - (!step.selector || step.selector.trim() === "") - ) { - selErrs.add(step.id); - } + if (stepIsMissingTarget(step)) selErrs.add(step.id); } selectorErrors.value = selErrs; @@ -268,10 +345,14 @@ defineExpose({ stopActiveRecording, stopActiveReplay, validateStepSelectors: validateJourneySteps, + // The parent view owns the zod parse, so it pushes the resulting issues back + // down here to be rendered against the fields they name. `fieldError` stays + // internal — the template is its only caller. + setStepFieldErrors, }); function startRecording() { - recorder.startRecording(props.startUrl ?? "").catch((err) => { + recorder.startRecording(props.startUrl ?? "", props.testIdAttr).catch((err) => { console.log("error ---", err); recorder.error.value = err instanceof Error ? err.message : String(err); }); @@ -409,8 +490,12 @@ function handleInsertBelow(row: BrowserStep) { id: getUUIDv7(true), action: "click", name: "", - timeout: 30000, code: "", + // A new step is a version-2 step: its identity is the locator bundle, never a + // bare `selector`. Seeding it empty is what makes the editor render the + // Locator block from the start, and what lets isV2Journey stay true once the + // author supplies a locator instead of flipping the journey to v1 (SE-18). + locator: { candidates: [], user_override: null }, }); emit("update:modelValue", next); } @@ -426,7 +511,14 @@ function handleUpdateExpanded(ids: string[]) { function addStep() { emit("update:modelValue", [ ...props.modelValue, - { id: getUUIDv7(true), action: "click", name: "", timeout: 30000, code: "" }, + { + id: getUUIDv7(true), + action: "click", + name: "", + code: "", + // See handleInsertBelow — a new step is version 2. + locator: { candidates: [], user_override: null }, + }, ]); } function duplicateCapturedStep(index: number, step: BrowserStep) { @@ -447,44 +539,16 @@ function getRowStatusColor(row: BrowserStep): string | undefined { return undefined; } -// ── Inline editor helpers ────────────────────────────────────────────────── -const selectorActions = SELECTOR_ACTIONS_CONST; -const valueActions = VALUE_ACTIONS_CONST; -const selectorTypeOptions = SELECTOR_TYPE_OPTIONS; - -function valueActionLabel(action: string): string { - return VALUE_LABELS[action] || t("synthetics.journey.valueFallback"); -} - -function valueWidthClass(action: string): string { - return VALUE_WIDTH_MAP[action] || "w-152!"; -} - -function valueTooltip(action: string): string | undefined { - return VALUE_TOOLTIP_MAP[action]; -} - -function handleStepUpdate(row: BrowserStep, patch: Partial) { +// ── Inline editor ────────────────────────────────────────────────────────── +// BrowserJourneyStepEditor owns the field rendering AND the wire sync, and +// emits a complete replacement step. Keeping a second copy of that logic here +// is what let the two editors drift apart in the first place. +function handleStepReplace(row: BrowserStep, next: BrowserStep) { const idx = findIndex(row); if (idx < 0) return; - const next = [...props.modelValue]; - - // Sync edits into the recorded wire step so the API receives the updated - // values. journeyToWireSteps prefers wire over UI fields, so without this - // sync, edits to recorded steps are silently discarded on save. - let wire = next[idx].wire ? { ...next[idx].wire } : undefined; - if (wire) { - if (patch.name !== undefined) wire.name = patch.name; - if (patch.selector !== undefined) wire.selector = patch.selector; - if (patch.selectorType !== undefined) - wire.selector_type = patch.selectorType.toLowerCase() as WireStep["selector_type"]; - if (patch.value !== undefined) wire.value = patch.value; - if (patch.timeout !== undefined) wire.timeout_ms = patch.timeout; - if (patch.action !== undefined) wire = undefined; // action changed → wire metadata is no longer accurate - } - - next[idx] = { ...next[idx], wire, ...patch }; - emit("update:modelValue", next); + const steps = [...props.modelValue]; + steps[idx] = next; + emit("update:modelValue", steps); } function openChromeExtensions() { @@ -608,6 +672,31 @@ function openChromeExtensions() { + + + + + + + + +
- + diff --git a/web/src/components/synthetics/journey/BrowserJourneyAssertion.spec.ts b/web/src/components/synthetics/journey/BrowserJourneyAssertion.spec.ts new file mode 100644 index 0000000000..d8e059c948 --- /dev/null +++ b/web/src/components/synthetics/journey/BrowserJourneyAssertion.spec.ts @@ -0,0 +1,92 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import type { StepAssertion } from "@/types/synthetics"; +import BrowserJourneyAssertion from "./BrowserJourneyAssertion.vue"; +import en from "@/locales/languages/en-US.json"; + +const i18n = createI18n({ + legacy: false, + locale: "en-US", + fallbackLocale: "en-US", + messages: { "en-US": en as Record }, +}); + +function render(assertion?: StepAssertion) { + return mount(BrowserJourneyAssertion, { + props: { assertion }, + global: { plugins: [i18n] }, + }); +} + +const test = (name: string) => `[data-test="${name}"]`; +const EXPECTED = test("synthetics-journey-step-assertion-expected-input"); +const ATTRIBUTE = test("synthetics-journey-step-assertion-attribute-input"); + +describe("BrowserJourneyAssertion", () => { + // An assert step that predates Phase 5 keeps its original meaning rather than + // rendering as an empty, invalid form. + it("defaults to element_visible when the step has no typed assertion", () => { + const wrapper = render(); + expect(wrapper.find(test("synthetics-journey-step-assertion")).exists()).toBe(true); + // Visibility asks "is it there?" — there is nothing to compare. + expect(wrapper.find(EXPECTED).exists()).toBe(false); + expect(wrapper.find(ATTRIBUTE).exists()).toBe(false); + }); + + it("asks for an expected value on every kind except the visibility ones", () => { + expect(render({ kind: "element_text", expected: "Welcome" }).find(EXPECTED).exists()).toBe( + true, + ); + expect(render({ kind: "url_matches", expected: "**/web/**" }).find(EXPECTED).exists()).toBe( + true, + ); + expect(render({ kind: "page_title", expected: "Dashboard" }).find(EXPECTED).exists()).toBe( + true, + ); + expect(render({ kind: "element_not_visible" }).find(EXPECTED).exists()).toBe(false); + }); + + it("asks for an attribute name only for element_attribute", () => { + expect( + render({ kind: "element_attribute", attribute: "href", expected: "/web/" }) + .find(ATTRIBUTE) + .exists(), + ).toBe(true); + expect(render({ kind: "element_text", expected: "x" }).find(ATTRIBUTE).exists()).toBe(false); + }); + + it("emits the edited expected value", async () => { + const wrapper = render({ kind: "element_text", expected: "" }); + await wrapper.find(`${EXPECTED} input`).setValue("Welcome back"); + const emitted = wrapper.emitted("update:assertion")?.at(-1)?.[0] as StepAssertion; + expect(emitted).toEqual({ kind: "element_text", expected: "Welcome back" }); + }); + + // A stale `attribute` left on a page_title assertion would be refused by + // server validation with no visible cause, so switching kind drops what no + // longer applies. + it("drops values that no longer apply when the kind changes", async () => { + const wrapper = render({ kind: "element_attribute", attribute: "href", expected: "/web/" }); + (wrapper.vm as unknown as { kindComputed: string }).kindComputed = "element_visible"; + await wrapper.vm.$nextTick(); + + const emitted = wrapper.emitted("update:assertion")?.at(-1)?.[0] as StepAssertion; + expect(emitted).toEqual({ kind: "element_visible" }); + }); + + it("offers exactly the closed kind set the server accepts", () => { + const wrapper = render(); + const options = (wrapper.vm as unknown as { kindOptions: { value: string }[] }).kindOptions; + expect(options.map((o) => o.value)).toEqual([ + "element_visible", + "element_not_visible", + "element_text", + "url_matches", + "page_title", + "element_attribute", + ]); + }); +}); diff --git a/web/src/components/synthetics/journey/BrowserJourneyAssertion.vue b/web/src/components/synthetics/journey/BrowserJourneyAssertion.vue new file mode 100644 index 0000000000..1c2bf1445a --- /dev/null +++ b/web/src/components/synthetics/journey/BrowserJourneyAssertion.vue @@ -0,0 +1,104 @@ + + + diff --git a/web/src/components/synthetics/journey/BrowserJourneyLocator.spec.ts b/web/src/components/synthetics/journey/BrowserJourneyLocator.spec.ts new file mode 100644 index 0000000000..43f0bbf6b6 --- /dev/null +++ b/web/src/components/synthetics/journey/BrowserJourneyLocator.spec.ts @@ -0,0 +1,289 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import type { StepLocator } from "@/types/synthetics"; +import BrowserJourneyLocator from "./BrowserJourneyLocator.vue"; +import en from "@/locales/languages/en-US.json"; + +const i18n = createI18n({ + legacy: false, + locale: "en-US", + fallbackLocale: "en-US", + messages: { "en-US": en as Record }, +}); + +const BUNDLE: StepLocator = { + candidates: [ + { kind: "test_attribute", value: '[data-test="login-sign-in"]' }, + { kind: "role", value: 'role=button[name="Sign In"]' }, + { kind: "css", value: ".btn-primary" }, + ], +}; + +function render(locator: StepLocator = BUNDLE) { + return mount(BrowserJourneyLocator, { + props: { locator }, + global: { plugins: [i18n] }, + }); +} + +const test = (name: string) => `[data-test="${name}"]`; + +describe("BrowserJourneyLocator", () => { + it("shows the primary candidate as the effective locator", () => { + const wrapper = render(); + const primary = wrapper.find(test("synthetics-journey-step-locator-primary")); + expect(primary.text()).toContain('[data-test="login-sign-in"]'); + expect(primary.text()).toContain("Test attribute"); + }); + + // The fallbacks used to sit behind an `OCollapsible "N fallbacks"` (P2.5/T5). + // A count advertised nothing, and the list is what the runner will actually try + // if the primary stops matching, so the click bought the author no information. + it("shows every remaining candidate without a disclosure", () => { + const wrapper = render(); + expect(wrapper.find(test("synthetics-journey-step-locator-fallbacks")).exists()).toBe(true); + const list = wrapper.find(test("synthetics-journey-step-locator-fallbacks")).text(); + expect(list).toContain('role=button[name="Sign In"]'); + expect(list).toContain(".btn-primary"); + expect(wrapper.text()).not.toContain("2 fallbacks"); + }); + + it("says what the fallback list is for rather than how long it is", () => { + const wrapper = render(); + expect(wrapper.find(test("synthetics-journey-step-locator-fallbacks-lead")).text()).toMatch( + /tried in order/i, + ); + }); + + // A pinned step never falls back, so the lead-in would be a lie; the pinned note + // stands in for it and the rows render inert. + it("drops the fallback lead-in when the step is pinned", () => { + const wrapper = render({ ...BUNDLE, user_override: { kind: "css", value: "#pinned" } }); + expect(wrapper.find(test("synthetics-journey-step-locator-fallbacks-lead")).exists()).toBe( + false, + ); + expect(wrapper.find(test("synthetics-journey-step-locator-pinned-note")).exists()).toBe(true); + }); + + // P2.5.0 — the list is machine-derived evidence. Pinning is the only way for + // an author to express intent, which is what keeps the stored list + // byte-comparable for the self-healing precondition. + it("emits a user_override when a candidate is pinned, leaving the list untouched", async () => { + const wrapper = render(); + await wrapper.find(test("synthetics-journey-step-locator-pin-primary-btn")).trigger("click"); + + const emitted = wrapper.emitted("update:locator")?.[0]?.[0] as StepLocator; + expect(emitted.user_override).toEqual({ + kind: "test_attribute", + value: '[data-test="login-sign-in"]', + }); + expect(emitted.candidates).toEqual(BUNDLE.candidates); + }); + + it("pins a fallback candidate without reordering the list", async () => { + const wrapper = render(); + const pinButtons = wrapper.findAll(test("synthetics-journey-step-locator-pin-btn")); + expect(pinButtons.length).toBe(2); + await pinButtons[0].trigger("click"); + + const emitted = wrapper.emitted("update:locator")?.[0]?.[0] as StepLocator; + expect(emitted.user_override?.value).toBe('role=button[name="Sign In"]'); + expect(emitted.candidates.map((c) => c.value)).toEqual(BUNDLE.candidates.map((c) => c.value)); + }); + + it("shows the pinned locator as the effective one, and says it will not fall back", () => { + const wrapper = render({ + ...BUNDLE, + user_override: { kind: "css", value: "#pinned" }, + }); + expect(wrapper.find(test("synthetics-journey-step-locator-primary")).text()).toContain( + "#pinned", + ); + expect(wrapper.find(test("synthetics-journey-step-locator-pinned-note")).exists()).toBe(true); + }); + + it("clears the pin, restoring fallback", async () => { + const wrapper = render({ ...BUNDLE, user_override: { kind: "css", value: "#pinned" } }); + await wrapper.find(test("synthetics-journey-step-locator-unpin-btn")).trigger("click"); + const emitted = wrapper.emitted("update:locator")?.[0]?.[0] as StepLocator; + expect(emitted.user_override).toBeNull(); + }); + + // Free text is intent, not evidence. Editing a candidate in place would + // corrupt the list the healing precondition compares against. + it("turns free text into a user_override rather than editing a candidate", async () => { + const wrapper = render(); + await wrapper + .find(`${test("synthetics-journey-step-locator-override-input")} input`) + .setValue("#hand-written"); + await wrapper.find(test("synthetics-journey-step-locator-override-btn")).trigger("click"); + + const emitted = wrapper.emitted("update:locator")?.[0]?.[0] as StepLocator; + expect(emitted.user_override).toEqual({ kind: "css", value: "#hand-written" }); + expect(emitted.candidates).toEqual(BUNDLE.candidates); + }); + + it("renders a single-candidate bundle with no fallback section", () => { + const wrapper = render({ candidates: [{ kind: "css", value: "#only" }] }); + expect(wrapper.find(test("synthetics-journey-step-locator-primary")).text()).toContain("#only"); + expect(wrapper.find(test("synthetics-journey-step-locator-fallbacks")).exists()).toBe(false); + }); +}); + +describe("all-positional notice (Phase 2a)", () => { + const positional = { + candidates: [ + { kind: "test_attribute" as const, value: '[data-test="org-item"] >> nth=1' }, + { kind: "css" as const, value: "div >> internal:has-text=/^Acme$/ >> nth=0" }, + ], + user_override: null, + }; + + it("warns when every candidate identifies the element by position", () => { + const wrapper = mount(BrowserJourneyLocator, { + props: { locator: positional }, + global: { plugins: [i18n] }, + }); + expect( + wrapper.find('[data-test="synthetics-journey-step-locator-positional-warning"]').exists(), + ).toBe(true); + }); + + it("stays silent when any candidate is unambiguous", () => { + const wrapper = mount(BrowserJourneyLocator, { + props: { + locator: { + candidates: [ + { kind: "role" as const, value: 'internal:role=button[name="Save"i]' }, + { kind: "test_attribute" as const, value: '[data-test="org-item"] >> nth=1' }, + ], + user_override: null, + }, + }, + global: { plugins: [i18n] }, + }); + expect( + wrapper.find('[data-test="synthetics-journey-step-locator-positional-warning"]').exists(), + ).toBe(false); + }); + + it("stays silent once the author has pinned — the question is answered", () => { + const wrapper = mount(BrowserJourneyLocator, { + props: { + locator: { ...positional, user_override: { kind: "css" as const, value: "#chosen" } }, + }, + global: { plugins: [i18n] }, + }); + expect( + wrapper.find('[data-test="synthetics-journey-step-locator-positional-warning"]').exists(), + ).toBe(false); + }); +}); + +// ── Empty bundle ────────────────────────────────────────────────────────── +// A hand-added step carries `{ candidates: [], user_override: null }`. Every +// other block is v-if'd away, so the free-text input is the PRIMARY way to name +// the element — not an override of something — and it must say so, and be marked +// required, because the block only renders when a target is needed (D7). +describe("BrowserJourneyLocator empty bundle", () => { + const EMPTY: StepLocator = { candidates: [], user_override: null }; + + // `data-test` sits on the OInput root, so reach the inner to set a + // value — the pattern the pinning tests above already use. + function overrideInput(wrapper: ReturnType) { + return wrapper.find(`${test("synthetics-journey-step-locator-override-input")} input`); + } + + it("labels the input as the primary control, not an override", () => { + const wrapper = render(EMPTY); + expect(overrideInput(wrapper).exists()).toBe(true); + expect(wrapper.text()).toContain("How to find this element"); + expect(wrapper.text()).not.toContain("Use a different locator"); + }); + + it("shows no primary card, no fallbacks and no positional warning", () => { + const wrapper = render(EMPTY); + expect(wrapper.find(test("synthetics-journey-step-locator-primary")).exists()).toBe(false); + expect(wrapper.find(test("synthetics-journey-step-locator-fallbacks")).exists()).toBe(false); + expect(wrapper.find(test("synthetics-journey-step-locator-positional-warning")).exists()).toBe( + false, + ); + }); + + it("marks the input required — the block only renders when a target is needed", () => { + const wrapper = render(EMPTY); + expect(wrapper.findComponent({ name: "OInput" }).props("required")).toBe(true); + }); + + it("still emits update:locator when a value is applied", async () => { + const wrapper = render(EMPTY); + await overrideInput(wrapper).setValue('[data-test="sign-in"]'); + await wrapper.find(test("synthetics-journey-step-locator-override-btn")).trigger("click"); + + const emitted = wrapper.emitted("update:locator"); + expect(emitted).toBeTruthy(); + expect(emitted![0][0]).toEqual({ + candidates: [], + user_override: { kind: "css", value: '[data-test="sign-in"]' }, + }); + }); + + it("keeps the override label, unmarked, when candidates exist", () => { + const wrapper = render(); + expect(wrapper.text()).toContain("Use a different locator"); + expect(wrapper.findComponent({ name: "OInput" }).props("required")).toBe(false); + }); +}); + +// Phase 4 / SE-8, D3. The kind is derived from the value, never picked — `kind` +// labels a locator, it does not parse it, so a picker that only set `kind` would +// store `{ kind: "role", value: "button" }` where `button` resolves as CSS. +describe("BrowserJourneyLocator derived kind", () => { + const typeOverride = async (wrapper: ReturnType, value: string) => { + await wrapper + .find(`${test("synthetics-journey-step-locator-override-input")} input`) + .setValue(value); + }; + + it("stores the kind read from the value, not css", async () => { + const wrapper = render(); + await typeOverride(wrapper, 'internal:role=button[name="Sign In"i]'); + await wrapper.find(test("synthetics-journey-step-locator-override-btn")).trigger("click"); + + const emitted = wrapper.emitted("update:locator")!; + expect((emitted[0][0] as StepLocator).user_override).toEqual({ + kind: "role", + value: 'internal:role=button[name="Sign In"i]', + }); + }); + + it("still stores css for a bare attribute selector", async () => { + const wrapper = render(); + await typeOverride(wrapper, '[data-qa="submit"]'); + await wrapper.find(test("synthetics-journey-step-locator-override-btn")).trigger("click"); + const emitted = wrapper.emitted("update:locator")!; + expect((emitted[0][0] as StepLocator).user_override?.kind).toBe("css"); + }); + + it("shows the derived kind as a read-only badge while typing", async () => { + const wrapper = render(); + expect(wrapper.find(test("synthetics-journey-step-locator-derived-kind")).exists()).toBe(false); + await typeOverride(wrapper, "text=Sign in"); + expect(wrapper.find(test("synthetics-journey-step-locator-derived-kind")).text()).toBe("Text"); + }); + + it("prefills the override from a candidate without carrying its stored kind", async () => { + const wrapper = render(); + await wrapper + .find(test("synthetics-journey-step-locator-start-from-primary-btn")) + .trigger("click"); + const input = wrapper.find(`${test("synthetics-journey-step-locator-override-input")} input`); + // Primary candidate is a bare [data-test=…] stored as test_attribute; the badge + // describes what is in the box, which is CSS. Documented and intended (D3). + expect((input.element as HTMLInputElement).value).toBe('[data-test="login-sign-in"]'); + expect(wrapper.find(test("synthetics-journey-step-locator-derived-kind")).text()).toBe("CSS"); + }); +}); diff --git a/web/src/components/synthetics/journey/BrowserJourneyLocator.vue b/web/src/components/synthetics/journey/BrowserJourneyLocator.vue new file mode 100644 index 0000000000..1599bf7644 --- /dev/null +++ b/web/src/components/synthetics/journey/BrowserJourneyLocator.vue @@ -0,0 +1,307 @@ + + + diff --git a/web/src/components/synthetics/journey/BrowserJourneyStep.spec.ts b/web/src/components/synthetics/journey/BrowserJourneyStep.spec.ts index 006dedebb5..1711745156 100644 --- a/web/src/components/synthetics/journey/BrowserJourneyStep.spec.ts +++ b/web/src/components/synthetics/journey/BrowserJourneyStep.spec.ts @@ -17,7 +17,7 @@ const OInputStub = { props: ["modelValue", "label", "placeholder", "type"], emits: ["update:modelValue"], template: - '', + '', }; const OSelectStub = { props: ["modelValue", "label", "options"], @@ -156,28 +156,25 @@ describe("BrowserJourneyStep", () => { expect(actionSelect.exists()).toBe(false); }); - it("should show selector fields for click action", () => { + // The v1 Selector-type + Selector pair is gone (SE-7 / SE-18): a version-2 + // step names its element through the locator bundle, and that is the only + // targeting UI. `stepNeedsTarget` decides when it renders. + it("should show the target block for click action", () => { wrapper = mountStep({ step: makeStep({ action: "click", selector: "#btn" }), expanded: true, }); - const selectorInput = wrapper.find('[data-test="synthetics-journey-step-selector-input"]'); - const selectorTypeSelect = wrapper.find( - '[data-test="synthetics-journey-step-selector-type-select"]', - ); - expect(selectorInput.exists()).toBe(true); - expect(selectorTypeSelect.exists()).toBe(true); + expect(wrapper.find('[data-test="synthetics-journey-step-locator"]').exists()).toBe(true); }); - it("should hide selector fields for navigate action", () => { + it("should hide the target block for navigate action", () => { wrapper = mountStep({ step: makeStep({ action: "navigate", value: "https://example.com" }), expanded: true, }); - const selectorInput = wrapper.find('[data-test="synthetics-journey-step-selector-input"]'); - expect(selectorInput.exists()).toBe(false); + expect(wrapper.find('[data-test="synthetics-journey-step-locator"]').exists()).toBe(false); }); it("should show value input for type action", () => { @@ -200,6 +197,90 @@ describe("BrowserJourneyStep", () => { expect(valueInput.exists()).toBe(true); }); + // ── Timeout guard rails (spec P1.1.4, P1.1.5 / T1-13, T1-14) ─────────── + // The recorder no longer stamps a timeout, so this field renders empty. An + // empty box reads as "no timeout", which is wrong and invites needless + // overrides — show the default the runner will actually apply. + describe("timeout guard rails", () => { + const timeoutInput = (w: VueWrapper) => + w.find('[data-test="synthetics-journey-step-timeout-input"]'); + const warning = (w: VueWrapper) => + w.find('[data-test="synthetics-journey-step-timeout-warning"]'); + + // SE-5 moved the timeout behind the one `Advanced` collapsible, which + // opens itself only when the step carries a non-default. A step with no + // explicit timeout is exactly the case that stays closed, and + // OCollapsible unmounts collapsed content — so these cases have to open + // it before the field is in the DOM. + const openAdvanced = (w: VueWrapper) => + w.find('[data-test="synthetics-journey-step-group-advanced"] button').trigger("click"); + + it("shows the 60s navigate/assert default as placeholder when unset", async () => { + wrapper = mountStep({ + step: makeStep({ action: "navigate", timeout: undefined }), + expanded: true, + }); + await openAdvanced(wrapper); + expect(timeoutInput(wrapper).attributes("placeholder")).toBe("60000"); + }); + + it("shows the 30s interaction default as placeholder when unset", async () => { + wrapper = mountStep({ + step: makeStep({ action: "click", timeout: undefined }), + expanded: true, + }); + await openAdvanced(wrapper); + expect(timeoutInput(wrapper).attributes("placeholder")).toBe("30000"); + }); + + it("warns when the author lowers the timeout below the category default", () => { + wrapper = mountStep({ + step: makeStep({ action: "click", timeout: 5000 }), + expanded: true, + }); + expect(warning(wrapper).exists()).toBe(true); + }); + + it("does not warn at or above the category default", () => { + wrapper = mountStep({ + step: makeStep({ action: "click", timeout: 30000 }), + expanded: true, + }); + expect(warning(wrapper).exists()).toBe(false); + }); + + // Opens `Advanced` first, or the absence proves nothing: an unset timeout + // is the one case that leaves the section collapsed, so the warning would + // be missing whether or not the component wanted to render it. + it("does not warn when no explicit timeout is set", async () => { + wrapper = mountStep({ + step: makeStep({ action: "click", timeout: undefined }), + expanded: true, + }); + await openAdvanced(wrapper); + expect(timeoutInput(wrapper).exists()).toBe(true); + expect(warning(wrapper).exists()).toBe(false); + }); + + // The warning is advisory. Lowering is the author's call — it must never + // block, only inform. + it("keeps the timeout editable while warning", () => { + wrapper = mountStep({ + step: makeStep({ action: "click", timeout: 5000 }), + expanded: true, + }); + expect(timeoutInput(wrapper).attributes("disabled")).toBeUndefined(); + }); + }); + + // ── Retired actions (spec X-9 / T1-9) ────────────────────────────────── + // T1-9 is "the editor no longer OFFERS the four retired actions" — the + // picker filter, covered by constants/synthetics.spec.ts. The per-step + // notice this file used to assert was deleted deliberately: `actionOptions` + // filters RETIRED_ACTIONS out, the recorder never emits one, and no v1 + // journeys exist, so nothing could reach it — and it named no replacement. + // Its absence is pinned by BrowserJourneyStepEditor.spec.ts. + it("should show timeout input when expanded", () => { wrapper = mountStep({ step: makeStep(), expanded: true }); @@ -403,4 +484,171 @@ describe("BrowserJourneyStep", () => { expect(dot.exists()).toBe(true); }); }); + + // ── Value inputs per action ─────────────────────────────────────────────── + describe("value input", () => { + const valueInput = () => wrapper.find('[data-test="synthetics-journey-step-value-input"]'); + + it("should render the file path input for an upload step", () => { + wrapper = mountStep({ + step: makeStep({ action: "upload", value: "/tmp/a.pdf" }), + expanded: true, + }); + + expect(valueInput().exists()).toBe(true); + expect(valueInput().attributes("value")).toBe("/tmp/a.pdf"); + }); + + it("should render the option input for a select step", () => { + wrapper = mountStep({ + step: makeStep({ action: "select", value: "India" }), + expanded: true, + }); + + expect(valueInput().attributes("value")).toBe("India"); + }); + + it("should not render a generic value input for an assert step", () => { + // BrowserJourneyAssertion owns the expected value; a second input took + // typing and had it dropped at save (buildV2Steps drops `value` on assert). + wrapper = mountStep({ + step: makeStep({ action: "assert", assertion: { kind: "element_text", expected: "Hi" } }), + expanded: true, + }); + + expect(valueInput().exists()).toBe(false); + }); + }); + + // ── Wire round-trip on edit ─────────────────────────────────────────────── + describe("value edits reach the replayed wire step", () => { + function editedWire(step: BrowserStep, newValue: string) { + wrapper = mountStep({ step, expanded: true }); + const input = wrapper.find('[data-test="synthetics-journey-step-value-input"]'); + input.setValue(newValue); + const emitted = wrapper.emitted("update:step") as BrowserStep[][]; + return emitted[0][0].wire!; + } + + it("should write an edited navigate URL to wire.url, not wire.value", async () => { + const step = makeStep({ + action: "navigate", + value: "https://old.test", + wire: { id: "step-1", action: "navigate", url: "https://old.test", pageAlias: "page" }, + }); + + const wire = editedWire(step, "https://new.test"); + + expect(wire.url).toBe("https://new.test"); + expect(wire.pageAlias).toBe("page"); // extension metadata survives the edit + }); + + it("should write an edited press key to wire.key", async () => { + const step = makeStep({ + action: "press", + value: "Enter", + wire: { id: "step-1", action: "press", key: "Enter" }, + }); + + expect(editedWire(step, "Tab").key).toBe("Tab"); + }); + + it("should write an edited select option to wire.options", async () => { + const step = makeStep({ + action: "select", + value: "India", + wire: { id: "step-1", action: "select", options: ["India"] }, + }); + + expect(editedWire(step, "Japan").options).toEqual(["Japan"]); + }); + }); + + // ── Settle block (spec P4.1.5, P3.4.3) ──────────────────────────────────── + describe("settle", () => { + const settleStep = () => + makeStep({ + action: "click", + settle: { + navigation: { url_pattern: "**/web/**" }, + responses: [{ url_pattern: "**/api/login", method: "POST", required: false }], + observed_duration_ms: 1200, + budget_ms: 5000, + }, + wire: { id: "step-1", action: "click" }, + }); + + it("should let the author mark a recorded response as required", async () => { + wrapper = mountStep({ step: settleStep(), expanded: true }); + + const checkbox = wrapper.find('[data-test="synthetics-journey-step-settle-required-0"]'); + expect(checkbox.exists()).toBe(true); + + await checkbox.setValue(true); + + const emitted = wrapper.emitted("update:step") as BrowserStep[][]; + const next = emitted[0][0]; + expect(next.settle?.responses?.[0].required).toBe(true); + // ...and it travels with the replayed step, not just the saved one. + expect(next.wire?.settle?.responses?.[0].required).toBe(true); + }); + + it("should show the settle budget and let the author change it", async () => { + wrapper = mountStep({ step: settleStep(), expanded: true }); + + const budget = wrapper.find('[data-test="synthetics-journey-step-settle-budget-input"]'); + expect(budget.attributes("value")).toBe("5000"); + + await budget.setValue("12000"); + + const emitted = wrapper.emitted("update:step") as BrowserStep[][]; + expect(emitted[0][0].settle?.budget_ms).toBe(12000); + }); + + it("should drop the budget when the field is cleared", async () => { + wrapper = mountStep({ step: settleStep(), expanded: true }); + + await wrapper.find('[data-test="synthetics-journey-step-settle-budget-input"]').setValue(""); + + const emitted = wrapper.emitted("update:step") as BrowserStep[][]; + const next = emitted[0][0]; + expect(next.settle?.budget_ms).toBeUndefined(); + expect(next.settle?.navigation?.url_pattern).toBe("**/web/**"); // evidence kept + }); + + it("should warn when the budget falls outside the server-accepted range", () => { + const step = settleStep(); + step.settle!.budget_ms = 90000; + wrapper = mountStep({ step, expanded: true }); + + expect( + wrapper.find('[data-test="synthetics-journey-step-settle-budget-warning"]').exists(), + ).toBe(true); + }); + + it("should not warn for a budget inside the range", () => { + wrapper = mountStep({ step: settleStep(), expanded: true }); + + expect( + wrapper.find('[data-test="synthetics-journey-step-settle-budget-warning"]').exists(), + ).toBe(false); + }); + + // Only the recorded lines are conditional. The block itself holds the budget + // input, which is the ONLY way to create a budget — gating it on "has settle + // data" made it unreachable on a hand-added step (SE-16). This asserted the + // wrapper's absence, which happened to hold while the settle fields sat in + // their own collapsed group; merging that group into `Advanced` means a step + // carrying any non-default (this fixture sets `timeout`) opens it. + it("should render no recorded evidence when the step has none", () => { + wrapper = mountStep({ step: makeStep({ action: "click" }), expanded: true }); + + expect(wrapper.find('[data-test="synthetics-journey-step-settle"]').text()).not.toContain( + "Waits for (recorded)", + ); + expect( + wrapper.find('[data-test="synthetics-journey-step-settle-budget-input"]').exists(), + ).toBe(true); + }); + }); }); diff --git a/web/src/components/synthetics/journey/BrowserJourneyStep.vue b/web/src/components/synthetics/journey/BrowserJourneyStep.vue index e55021dae6..8b051af592 100644 --- a/web/src/components/synthetics/journey/BrowserJourneyStep.vue +++ b/web/src/components/synthetics/journey/BrowserJourneyStep.vue @@ -3,16 +3,8 @@ import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; import { copyToClipboard } from "@/utils/clipboard"; -import type { BrowserStep, SelectorType, StepReplayResult, WireStep } from "@/types/synthetics"; -import { - ACTION_ICONS, - ACTION_LABELS, - SELECTOR_ACTIONS, - VALUE_ACTIONS, - VALUE_LABELS, - SELECTOR_TYPE_OPTIONS, - actionOptions, -} from "@/constants/synthetics"; +import type { BrowserStep, StepReplayResult } from "@/types/synthetics"; +import { ACTION_ICONS, ACTION_LABELS } from "@/constants/synthetics"; /** * Replay status dot states. @@ -21,12 +13,11 @@ import { */ export type StepDotState = "pending" | "active" | "pass" | "fail" | "skip"; import OButton from "@/lib/core/Button/OButton.vue"; -import OInput from "@/lib/forms/Input/OInput.vue"; -import OSelect from "@/lib/forms/Select/OSelect.vue"; import OIcon from "@/lib/core/Icon/OIcon.vue"; import OBadge from "@/lib/core/Badge/OBadge.vue"; import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue"; import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue"; +import BrowserJourneyStepEditor from "./BrowserJourneyStepEditor.vue"; const { t } = useI18n(); @@ -55,64 +46,20 @@ const emit = defineEmits<{ }>(); // ── Computed from shared constants ────────────────────────────────── -const selectorTypeOptions = SELECTOR_TYPE_OPTIONS; const actionIcon = computed(() => ACTION_ICONS[props.step.action]); const actionLabel = computed(() => ACTION_LABELS[props.step.action]); const displayName = computed(() => props.step.name || actionLabel.value); -const selectorPreview = computed(() => props.step.selector || props.step.value || ""); -const showSelector = computed(() => SELECTOR_ACTIONS.includes(props.step.action)); -const showValue = computed(() => VALUE_ACTIONS.includes(props.step.action)); -const valueLabel = computed( - () => VALUE_LABELS[props.step.action] || t("synthetics.journey.valueFallback"), -); - -function update(patch: Partial) { - // Patch edited fields into wire instead of clearing it, so replay still has - // the original extension metadata (framePath, pageAlias, position, snapshot). - // Action changes clear wire since the step type fundamentally changed. - let wire = props.step.wire ? { ...props.step.wire } : undefined; - if (wire) { - if (patch.name !== undefined) wire.name = patch.name; - if (patch.selector !== undefined) wire.selector = patch.selector; - if (patch.selectorType !== undefined) - wire.selector_type = patch.selectorType.toLowerCase() as WireStep["selector_type"]; - if (patch.value !== undefined) wire.value = patch.value; - if (patch.timeout !== undefined) wire.timeout_ms = patch.timeout; - if (patch.action !== undefined) wire = undefined; // action changed — wire metadata is no longer accurate - } - emit("update:step", { ...props.step, wire, ...patch }); -} - -// Computed getters/setters for inline editor fields -const actionComputed = computed({ - get: () => props.step.action, - set: (v: BrowserStep["action"]) => update({ action: v }), -}); - -const nameComputed = computed({ - get: () => props.step.name ?? "", - set: (v: string) => update({ name: v }), -}); - -const selectorTypeComputed = computed({ - get: () => props.step.selectorType ?? "CSS", - set: (v: string | number | boolean | null | undefined) => - update({ selectorType: (v as SelectorType) ?? undefined }), -}); - -const selectorComputed = computed({ - get: () => props.step.selector ?? "", - set: (v: string) => update({ selector: v }), -}); - -const valueComputed = computed({ - get: () => props.step.value ?? "", - set: (v: string) => update({ value: v }), -}); - -const timeoutComputed = computed({ - get: () => String(props.step.timeout ?? ""), - set: (v: string) => update({ timeout: v ? Number(v) : undefined }), +/** + * What the collapsed row shows on the right. + * + * A version-2 step has no bare `selector` — its identity is the locator bundle — + * so reading `selector` alone would leave every recorded step's row blank. The + * pin wins when there is one, because that is what the run will actually use. + */ +const selectorPreview = computed(() => { + const locator = props.step.locator; + const effective = locator?.user_override ?? locator?.candidates?.[0]; + return effective?.value || props.step.selector || props.step.value || ""; }); // ── Status dot visual mapping (combines with step number during replay) ───── @@ -406,62 +353,13 @@ function toggleExpanded() {
- -
- - - - - - - - - - - - - - -
+ + diff --git a/web/src/components/synthetics/journey/BrowserJourneyStepEditor.spec.ts b/web/src/components/synthetics/journey/BrowserJourneyStepEditor.spec.ts new file mode 100644 index 0000000000..3374ffc5dc --- /dev/null +++ b/web/src/components/synthetics/journey/BrowserJourneyStepEditor.spec.ts @@ -0,0 +1,392 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import type { BrowserStep } from "@/types/synthetics"; +import BrowserJourneyStepEditor from "./BrowserJourneyStepEditor.vue"; +import en from "@/locales/languages/en-US.json"; + +const i18n = createI18n({ + legacy: false, + locale: "en-US", + fallbackLocale: "en-US", + messages: { "en-US": en as Record }, +}); + +function render(step: Partial = {}) { + const full: BrowserStep = { + id: "s1", + action: "click", + name: "Sign in", + code: "", + locator: { candidates: [], user_override: null }, + ...step, + }; + return mount(BrowserJourneyStepEditor, { + props: { step: full }, + global: { plugins: [i18n] }, + }); +} + +const test = (name: string) => `[data-test="${name}"]`; + +// The locator bundle is the only way a v2 step names its element. The v1 +// Selector-type + Selector pair is gone: no v1 journeys exist, so the fork it +// served could not occur, and keeping it produced two unrelated targeting +// editors in one journey (SE-7) plus a silent steps_version downgrade (SE-18). +describe("BrowserJourneyStepEditor targeting", () => { + it("renders the locator block, never the v1 selector pair", () => { + const wrapper = render(); + expect(wrapper.find(test("synthetics-journey-step-locator")).exists()).toBe(true); + expect(wrapper.find(test("synthetics-journey-step-selector-type-select")).exists()).toBe(false); + expect(wrapper.find(test("synthetics-journey-step-selector-input")).exists()).toBe(false); + }); + + it("renders the locator block for a recorded step too", () => { + const wrapper = render({ + locator: { candidates: [{ kind: "test_attribute", value: '[data-test="x"]' }] }, + }); + expect(wrapper.find(test("synthetics-journey-step-locator")).exists()).toBe(true); + }); + + // SE-1: the form and the validator now share one predicate, so the form can + // neither demand a target the validator ignores nor omit one it requires. + it("renders no target block for a page-level assertion", () => { + const wrapper = render({ + action: "assert", + assertion: { kind: "url_matches", expected: "**/web/**" }, + }); + expect(wrapper.find(test("synthetics-journey-step-locator")).exists()).toBe(false); + }); + + it("renders the target block for an element-level assertion", () => { + const wrapper = render({ action: "assert", assertion: { kind: "element_visible" } }); + expect(wrapper.find(test("synthetics-journey-step-locator")).exists()).toBe(true); + }); + + it("renders no target block for navigate", () => { + const wrapper = render({ action: "navigate", value: "https://example.com" }); + expect(wrapper.find(test("synthetics-journey-step-locator")).exists()).toBe(false); + }); +}); + +// `hover` is retired. It can no longer reach a step — actionOptions filters +// RETIRED_ACTIONS out of the picker, the recorder has never emitted one +// (upstream's ActionName omits them entirely), and no v1 journeys exist, which +// was the only other way one could have entered a journey. The notice also named +// no replacement, so it told an author to fix something without saying how. +describe("BrowserJourneyStepEditor retired actions", () => { + it("renders no retired-action notice, even for a legacy action value", () => { + const wrapper = render({ action: "hover" }); + expect(wrapper.find(test("synthetics-journey-step-retired-action")).exists()).toBe(false); + }); +}); + +// The host parses with zod and pushes the resulting issues back down as per-field +// messages. Before this, validation was save-time and toast-only, so a failure +// named no field (SE-3). +describe("BrowserJourneyStepEditor inline field errors", () => { + function renderWithErrors(step: Partial, errors: Record) { + const full: BrowserStep = { + id: "s1", + action: "click", + name: "Sign in", + code: "", + locator: { candidates: [], user_override: null }, + ...step, + }; + return mount(BrowserJourneyStepEditor, { + props: { step: full, ...errors }, + global: { plugins: [i18n] }, + }); + } + + it("renders a name error on the name field", () => { + const wrapper = renderWithErrors({}, { nameErrorMessage: "Give this step a name" }); + expect(wrapper.find(test("synthetics-journey-step-name-input")).text()).toContain( + "Give this step a name", + ); + }); + + it("renders a value error on the value field", () => { + const wrapper = renderWithErrors( + { action: "type", value: "" }, + { valueErrorMessage: "Enter the text this step should type" }, + ); + expect(wrapper.find(test("synthetics-journey-step-value-input")).text()).toContain( + "Enter the text this step should type", + ); + }); + + it("renders an expected error on the assertion's expected field", () => { + const wrapper = renderWithErrors( + { action: "assert", assertion: { kind: "element_text", expected: "" } }, + { expectedErrorMessage: "Enter the value this assertion should expect" }, + ); + expect(wrapper.find(test("synthetics-journey-step-assertion-expected-input")).text()).toContain( + "Enter the value this assertion should expect", + ); + }); + + it("renders no error text when the host passes none", () => { + const wrapper = renderWithErrors({}, {}); + expect(wrapper.find(test("synthetics-journey-step-name-input")).text()).not.toContain( + "Give this step a name", + ); + }); +}); + +// Phase 2 / SE-5, revised. Two tiers, not three peer collapsibles: what the step +// DOES is plain always-visible markup, and everything a recording or a runner +// default already answers sits behind one `Advanced` collapsible. +// +// The three-group shape charged the same price for the step's identity as for its +// tuning — and wrapped always-visible content in a `default-open` collapsible, +// which is a control whose only effect is to let an author close what they need. +describe("BrowserJourneyStepEditor field layout", () => { + const groups = (wrapper: ReturnType) => + wrapper + .findAll('[data-test^="synthetics-journey-step-group-"]') + .map((g) => g.attributes("data-test")); + + it("renders the always-visible block and exactly one collapsible", () => { + const wrapper = render(); + expect(groups(wrapper)).toEqual([ + "synthetics-journey-step-group-does", + "synthetics-journey-step-group-advanced", + ]); + }); + + // OCollapsible unmounts collapsed content, so `Advanced` must be opened before + // its fields are in the DOM — it being closed by default is the point. + async function openAdvanced(wrapper: ReturnType) { + await wrapper.find(`${test("synthetics-journey-step-group-advanced")} button`).trigger("click"); + } + + it("needs no click to reach action, name, target and value", () => { + const wrapper = render({ action: "type", value: "hunter2" }); + const visible = wrapper.find(test("synthetics-journey-step-group-does")); + expect(visible.find(test("synthetics-journey-step-action-select")).exists()).toBe(true); + expect(visible.find(test("synthetics-journey-step-name-input")).exists()).toBe(true); + expect(visible.find(test("synthetics-journey-step-locator")).exists()).toBe(true); + expect(visible.find(test("synthetics-journey-step-value-input")).exists()).toBe(true); + }); + + // One disclosure in the whole editor. The locator block used to add a second + // (its "N fallbacks" collapse), which nested two levels of hiding over one short + // read-only list. + it("renders one disclosure region in the whole editor", () => { + const wrapper = render({ + locator: { + candidates: [ + { kind: "test_attribute", value: '[data-test="a"]' }, + { kind: "role", value: "role=button" }, + ], + }, + }); + expect(wrapper.findAll('[data-test="o-collapsible-content"]').length).toBe(1); + }); + + // SE-16: the settle fields used to be gated on hasSettle, so a hand-added step + // could never be given a budget — the field that creates one was hidden until + // one existed. + it("reaches the settle budget on a hand-added step with no settle data", async () => { + const wrapper = render(); + expect(wrapper.find(test("synthetics-journey-step-settle-budget-input")).exists()).toBe(false); + await openAdvanced(wrapper); + expect(wrapper.find(test("synthetics-journey-step-settle-budget-input")).exists()).toBe(true); + }); + + it("puts settling, timeout and flow control in Advanced", async () => { + const wrapper = render(); + await openAdvanced(wrapper); + const advanced = wrapper.find(test("synthetics-journey-step-group-advanced")); + expect(advanced.find(test("synthetics-journey-step-settle-budget-input")).exists()).toBe(true); + expect(advanced.find(test("synthetics-journey-step-timeout-input")).exists()).toBe(true); + expect(advanced.find(test("synthetics-journey-step-optional-checkbox")).exists()).toBe(true); + expect(advanced.find(test("synthetics-journey-step-always-run-checkbox")).exists()).toBe(true); + }); + + it("opens Advanced already when the step holds a non-default value", () => { + const wrapper = render({ optional: true }); + expect(wrapper.find(test("synthetics-journey-step-timeout-input")).exists()).toBe(true); + }); + + it("captions Advanced with what it is for when the step holds only defaults", () => { + const wrapper = render(); + const caption = wrapper.find(test("synthetics-journey-step-group-advanced")).text(); + expect(caption).toContain("Page settling"); + expect(caption).not.toMatch(/Optional|Always run|Timeout \d/); + }); + + it("captions Advanced with each non-default value instead", () => { + const wrapper = render({ optional: true, alwaysRun: true, timeout: 10000 }); + const caption = wrapper.find(test("synthetics-journey-step-group-advanced")).text(); + expect(caption).toContain("Optional"); + expect(caption).toContain("Always run"); + expect(caption).toContain("10"); + expect(caption).not.toContain("Page settling"); + }); + + it("captions Advanced when settle evidence was recorded", () => { + const wrapper = render({ + settle: { navigation: { url_pattern: "**/home" }, observed_duration_ms: 1200 }, + }); + expect(wrapper.find(test("synthetics-journey-step-group-advanced")).text()).toContain( + "recorded", + ); + }); +}); + +// Phase 2 / SE-15. The configure forms size fields with flex, not fixed widths; +// the step editor was the outlier with `!important` overrides that defeated reflow. +describe("BrowserJourneyStepEditor layout", () => { + it("uses no !important width overrides", () => { + const wrapper = render({ action: "type", value: "x" }); + const offenders = wrapper + .findAll("*") + .map((n) => n.attributes("class") ?? "") + .filter((c) => /\bw-\d+!/.test(c)); + expect(offenders).toEqual([]); + }); +}); + +// Phase 3 / SE-6, SE-9, SE-20. +describe("BrowserJourneyStepEditor plain language", () => { + it("leads with a sentence describing the step", () => { + const wrapper = render({ + action: "click", + locator: { candidates: [{ kind: "test_attribute", value: '[data-test="sign-in"]' }] }, + }); + const summary = wrapper.find(test("synthetics-journey-step-summary")).text(); + expect(summary).toContain("Click"); + expect(summary).toContain('[data-test="sign-in"]'); + // the effective timeout, not a raw ms number + expect(summary).toContain("30"); + }); + + it("uses the pinned locator in the summary, since that is what runs", () => { + const wrapper = render({ + locator: { + candidates: [{ kind: "css", value: ".ignored" }], + user_override: { kind: "css", value: "#pinned" }, + }, + }); + expect(wrapper.find(test("synthetics-journey-step-summary")).text()).toContain("#pinned"); + }); + + it("reflects an explicit timeout in the summary", () => { + const wrapper = render({ timeout: 5000 }); + expect(wrapper.find(test("synthetics-journey-step-summary")).text()).toContain("5"); + }); + + it("names the runner default in the timeout helper for an interaction", async () => { + const wrapper = render(); + await wrapper.find(`${test("synthetics-journey-step-group-advanced")} button`).trigger("click"); + const help = wrapper.find(test("synthetics-journey-step-timeout-help")).text(); + expect(help).toContain("30"); + expect(help).toContain("Maximum 60"); + }); + + // SE-20: on navigate/assert the category default IS the server maximum, so the + // field can only shorten — saying so stops the below-default warning reading as + // a malfunction. + it("says the field can only shorten on navigate, where default equals the maximum", async () => { + const wrapper = render({ action: "navigate", value: "https://example.com" }); + await wrapper.find(`${test("synthetics-journey-step-group-advanced")} button`).trigger("click"); + const help = wrapper.find(test("synthetics-journey-step-timeout-help")).text(); + expect(help).toContain("60"); + expect(help).toMatch(/only shorten/i); + }); + + it("renames the Playwright terms out of the visible copy", () => { + const wrapper = render({ + locator: { + candidates: [ + { kind: "css", value: "#a" }, + { kind: "css", value: "#b" }, + ], + }, + }); + const txt = wrapper.text(); + expect(txt).toContain("How to find this element"); + expect(txt).toContain("Always use this one"); + expect(txt).not.toMatch(/\bLocator\b/); + }); +}); + +// Phase 4 / SE-13, D11. Both flow-control flags are fully implemented in the probe +// with semantics the labels omit. Both-set is legitimate — run during cleanup, and +// if it fails do not fail the run — so this explains rather than prevents. +describe("BrowserJourneyStepEditor flow-control help", () => { + async function openAdvanced(wrapper: ReturnType) { + await wrapper.find(`${test("synthetics-journey-step-group-advanced")} button`).trigger("click"); + } + + it("attaches an info tooltip to each flag", async () => { + const wrapper = render(); + await openAdvanced(wrapper); + expect(wrapper.find(test("synthetics-journey-step-optional-help")).exists()).toBe(true); + expect(wrapper.find(test("synthetics-journey-step-always-run-help")).exists()).toBe(true); + }); + + it("explains the probe behaviour the labels omit", async () => { + const wrapper = render(); + await openAdvanced(wrapper); + const tips = wrapper.findAllComponents({ name: "OTooltip" }).map((c) => c.props("content")); + const optional = tips.find((c) => /Skipped/i.test(String(c))); + const always = tips.find((c) => /cleanup/i.test(String(c))); + expect(optional).toMatch(/never fails the run/i); + expect(always).toMatch(/after the failed one/i); + }); + + it("leaves both flags independently settable — the combination is legal", async () => { + const wrapper = render({ optional: true, alwaysRun: true }); + const optional = wrapper.find(test("synthetics-journey-step-optional-checkbox")); + const always = wrapper.find(test("synthetics-journey-step-always-run-checkbox")); + expect(optional.attributes("disabled")).toBeUndefined(); + expect(always.attributes("disabled")).toBeUndefined(); + }); +}); + +// Phase 5c / SE-11, D9. Discarding the wire on an action change is correct — its +// payload belongs to the old action — but it used to happen in silence. +// +// Scope note: after the storage path stopped preserving `wire` (SE-24), a step +// loaded from a saved monitor carries none, so there is nothing to discard and the +// notice correctly stays silent. It applies to a live recording session, which is +// the only place a wire is still present. +describe("BrowserJourneyStepEditor action-change notice", () => { + const selectAction = async (wrapper: ReturnType, action: string) => { + await wrapper.findComponent({ name: "OSelect" }).vm.$emit("update:modelValue", action); + }; + + it("says the step is rebuilt when a recorded wire is discarded", async () => { + const wrapper = render({ wire: { id: "w1", action: "click" } as never }); + expect(wrapper.find(test("synthetics-journey-step-action-changed-notice")).exists()).toBe( + false, + ); + + await selectAction(wrapper, "navigate"); + expect(wrapper.find(test("synthetics-journey-step-action-changed-notice")).text()).toMatch( + /rebuilds this step/i, + ); + }); + + it("stays silent for a stored step, which carries no wire to lose", async () => { + const wrapper = render(); // no `wire` — the shape mapWireSteps now produces + await selectAction(wrapper, "navigate"); + expect(wrapper.find(test("synthetics-journey-step-action-changed-notice")).exists()).toBe( + false, + ); + }); + + it("stays silent when the action is re-selected unchanged", async () => { + const wrapper = render({ wire: { id: "w1", action: "click" } as never }); + await selectAction(wrapper, "click"); + expect(wrapper.find(test("synthetics-journey-step-action-changed-notice")).exists()).toBe( + false, + ); + }); +}); diff --git a/web/src/components/synthetics/journey/BrowserJourneyStepEditor.vue b/web/src/components/synthetics/journey/BrowserJourneyStepEditor.vue new file mode 100644 index 0000000000..a9bcc5617c --- /dev/null +++ b/web/src/components/synthetics/journey/BrowserJourneyStepEditor.vue @@ -0,0 +1,579 @@ + + + diff --git a/web/src/components/synthetics/journey/BrowserJourneyStepError.spec.ts b/web/src/components/synthetics/journey/BrowserJourneyStepError.spec.ts new file mode 100644 index 0000000000..cb72c0ba37 --- /dev/null +++ b/web/src/components/synthetics/journey/BrowserJourneyStepError.spec.ts @@ -0,0 +1,111 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import type { StepReplayResult } from "@/types/synthetics"; +import BrowserJourneyStepError from "./BrowserJourneyStepError.vue"; +import en from "@/locales/languages/en-US.json"; + +const i18n = createI18n({ + legacy: false, + locale: "en-US", + fallbackLocale: "en-US", + messages: { "en-US": en as Record }, +}); + +const test = (name: string) => `[data-test="${name}"]`; + +function render(result: Partial = {}, stepNumber?: number) { + const full: StepReplayResult = { + stepId: "s3", + stepName: "Click sign in", + passed: false, + durationMs: 30_000, + error: "locator.click: Timeout 30000ms exceeded.", + ...result, + }; + return mount(BrowserJourneyStepError, { + props: { result: full, stepNumber }, + global: { plugins: [i18n] }, + }); +} + +// SE-4. This evidence was computed and thrown away: when JourneySteps replaced the +// old row component the error card was not carried across, so a failed replay showed +// only a red dot and a one-line journey banner. +describe("BrowserJourneyStepError", () => { + it("shows the error message", () => { + const wrapper = render(); + expect(wrapper.find(test("synthetics-journey-step-error-message")).text()).toContain( + "Timeout 30000ms exceeded", + ); + }); + + it("prefers the structured error's message over the raw string", () => { + const wrapper = render({ + structuredError: { name: "TimeoutError", message: "waiting for locator" } as never, + }); + expect(wrapper.find(test("synthetics-journey-step-error-message")).text()).toContain( + "waiting for locator", + ); + }); + + it("names the exit reason and the duration", () => { + const wrapper = render({ + structuredError: { name: "TimeoutError", message: "x" } as never, + }); + const card = wrapper.find(test("synthetics-journey-step-error-card")).text(); + expect(card).toMatch(/timeout/i); + expect(card).toContain("30.0 s"); + }); + + it("shows the selector the runner could not act on", () => { + const wrapper = render({ + structuredError: { + name: "TimeoutError", + message: "x", + selector: '[data-test="sign-in"]', + } as never, + }); + expect(wrapper.find(test("synthetics-journey-step-error-selector")).text()).toBe( + '[data-test="sign-in"]', + ); + }); + + // X-8.2 — "A step the player skipped MUST NOT render as a pass. Silent divergence + // is the failure mode this whole section exists to prevent." + it("renders the player's fidelity notes", () => { + const wrapper = render({ + fidelity: { + level: "reduced", + notes: [ + "primary locator only", + 'Flow control not simulated: the preview stops at the first failure regardless of "optional" or "always run".', + ], + }, + }); + const fidelity = wrapper.find(test("synthetics-journey-step-fidelity")); + expect(fidelity.exists()).toBe(true); + expect(fidelity.text()).toContain("primary locator only"); + expect(fidelity.text()).toContain("Flow control not simulated"); + }); + + it("omits the fidelity block when the player reported nothing", () => { + expect(render().find(test("synthetics-journey-step-fidelity")).exists()).toBe(false); + }); + + // SE-4: the old button said "Re-run" inside a per-step card but replayed the whole + // journey. A single step is not independently runnable, so the honest affordance + // names the prefix it will actually run. + it("names the prefix the re-run will execute", () => { + const wrapper = render({}, 3); + expect(wrapper.find(test("synthetics-journey-error-retry-btn")).text()).toContain("1–3"); + }); + + it("emits retry-replay when the re-run is clicked", async () => { + const wrapper = render({}, 3); + await wrapper.find(test("synthetics-journey-error-retry-btn")).trigger("click"); + expect(wrapper.emitted("retry-replay")).toBeTruthy(); + }); +}); diff --git a/web/src/components/synthetics/journey/BrowserJourneyStepError.vue b/web/src/components/synthetics/journey/BrowserJourneyStepError.vue new file mode 100644 index 0000000000..b44e5ad5f0 --- /dev/null +++ b/web/src/components/synthetics/journey/BrowserJourneyStepError.vue @@ -0,0 +1,151 @@ +// Copyright 2026 OpenObserve Inc. + + + + diff --git a/web/src/components/synthetics/journey/RecordJourney.spec.ts b/web/src/components/synthetics/journey/RecordJourney.spec.ts deleted file mode 100644 index 3e0b335c94..0000000000 --- a/web/src/components/synthetics/journey/RecordJourney.spec.ts +++ /dev/null @@ -1,287 +0,0 @@ -// Copyright 2026 OpenObserve Inc. - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mount, VueWrapper, flushPromises } from "@vue/test-utils"; - -// ── Stubs ────────────────────────────────────────────────────────────────── -const OButtonStub = { - template: '', -}; -const OIconStub = { - template: '', -}; -const OSpinnerStub = { - template: '
', -}; -const BrowserJourneyStepStub = { - props: [ - "step", - "index", - "expanded", - "selected", - "replayDotState", - "replayLocked", - "replayResult", - ], - emits: [ - "update:step", - "update:expanded", - "delete", - "duplicate", - "insert-below", - "toggle-select", - "retry-replay", - ], - template: - '
', -}; - -import i18n from "@/locales"; -import RecordJourney from "./RecordJourney.vue"; -import type { BrowserStep } from "@/types/synthetics"; - -function mountRecord(startUrl = "https://example.com") { - return mount(RecordJourney, { - props: { startUrl }, - global: { - plugins: [i18n], - stubs: { - OButton: OButtonStub, - OIcon: OIconStub, - OSpinner: OSpinnerStub, - BrowserJourneyStep: BrowserJourneyStepStub, - }, - }, - }) as VueWrapper; -} - -describe("RecordJourney", () => { - let wrapper: VueWrapper; - - beforeEach(() => { - vi.useFakeTimers(); - // mock crypto.randomUUID used in setTimeout callbacks - let uuidCounter = 0; - vi.spyOn(globalThis.crypto, "randomUUID").mockImplementation( - () => `mock-uuid-${uuidCounter++}`, - ); - }); - - afterEach(() => { - wrapper?.unmount(); - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - // ── Initial Render ─────────────────────────────────────────────────────── - describe("initial render", () => { - it("should render recording banner on mount", () => { - wrapper = mountRecord("https://app.test/login"); - - expect(wrapper.text()).toContain("Recording"); - }); - - it("should show the current URL from props", () => { - wrapper = mountRecord("https://app.test/login"); - - expect(wrapper.text()).toContain("https://app.test/login"); - }); - - it("should show timer starting at 00:00", () => { - wrapper = mountRecord(); - - expect(wrapper.text()).toContain("00:00"); - }); - - it("should render Stop & Review button", () => { - wrapper = mountRecord(); - - const stopBtn = wrapper.find('[data-test="synthetics-record-stop-btn"]'); - expect(stopBtn.exists()).toBe(true); - }); - - it("should render a Cancel button", () => { - wrapper = mountRecord(); - - // find the Cancel button by text since it has no data-test - const buttons = wrapper.findAll("button"); - const cancelBtn = buttons.find((btn) => btn.text() === "Cancel"); - expect(cancelBtn?.exists()).toBe(true); - }); - - it('should show "Waiting for actions" when no steps captured yet', () => { - wrapper = mountRecord(); - - expect(wrapper.text()).toContain("Waiting for actions in the browser…"); - }); - }); - - // ── Captured Steps ─────────────────────────────────────────────────────── - describe("captured steps", () => { - it("should show captured step after first timeout fires", async () => { - wrapper = mountRecord("https://app.test"); - - // Advance timers past 500ms to trigger the navigate step - await vi.advanceTimersByTimeAsync(600); - - // Step row should now appear - const stepStubs = wrapper.findAll(".journey-step-stub"); - expect(stepStubs.length).toBe(1); - expect(stepStubs[0].attributes("data-step-action")).toBe("navigate"); - expect(stepStubs[0].attributes("data-step-name")).toBe("Open start URL"); - }); - - it("should show all three captured steps after all timeouts fire", async () => { - wrapper = mountRecord("https://app.test"); - - await vi.advanceTimersByTimeAsync(5000); - - const stepStubs = wrapper.findAll(".journey-step-stub"); - expect(stepStubs.length).toBe(3); - }); - - it("should show step count in the header", async () => { - wrapper = mountRecord("https://app.test"); - - await vi.advanceTimersByTimeAsync(1000); - - expect(wrapper.text()).toContain("(1 steps)"); - }); - - it("should update step count as more steps arrive", async () => { - wrapper = mountRecord("https://app.test"); - - await vi.advanceTimersByTimeAsync(600); - expect(wrapper.text()).toContain("(1 steps)"); - - await vi.advanceTimersByTimeAsync(2000); - expect(wrapper.text()).toContain("(2 steps)"); - - await vi.advanceTimersByTimeAsync(3000); - expect(wrapper.text()).toContain("(3 steps)"); - }); - - it("should emit done with captured steps on stop", async () => { - wrapper = mountRecord("https://app.test"); - - // Let all steps capture - await vi.advanceTimersByTimeAsync(5000); - - const stopBtn = wrapper.find('[data-test="synthetics-record-stop-btn"]'); - await stopBtn.trigger("click"); - - const emitted = wrapper.emitted("done"); - expect(emitted).toBeTruthy(); - const steps = emitted![0][0] as BrowserStep[]; - expect(steps.length).toBe(3); - expect(steps[0].action).toBe("navigate"); - expect(steps[0].value).toBe("https://app.test"); - }); - - it("should emit done even when no steps captured", async () => { - wrapper = mountRecord(); - - // Don't advance timers — no steps - - const stopBtn = wrapper.find('[data-test="synthetics-record-stop-btn"]'); - await stopBtn.trigger("click"); - - const emitted = wrapper.emitted("done"); - expect(emitted).toBeTruthy(); - const steps = emitted![0][0] as BrowserStep[]; - expect(steps.length).toBe(0); - }); - }); - - // ── Cancel ─────────────────────────────────────────────────────────────── - describe("cancel", () => { - it("should emit cancel when cancel button is clicked", async () => { - wrapper = mountRecord(); - - const buttons = wrapper.findAll("button"); - const cancelBtn = buttons.find((btn) => btn.text() === "Cancel"); - expect(cancelBtn?.exists()).toBe(true); - - await cancelBtn!.trigger("click"); - expect(wrapper.emitted("cancel")).toBeTruthy(); - }); - }); - - // ── Timer ──────────────────────────────────────────────────────────────── - describe("timer", () => { - it("should advance timer every second", async () => { - wrapper = mountRecord(); - - // Initially 00:00 - expect(wrapper.text()).toContain("00:00"); - - await vi.advanceTimersByTimeAsync(3000); - - // After 3 seconds, should show 00:03 - expect(wrapper.text()).toContain("00:03"); - }); - - it("should format minutes correctly", async () => { - wrapper = mountRecord(); - - await vi.advanceTimersByTimeAsync(65000); - - // 65 seconds = 01:05 - expect(wrapper.text()).toContain("01:05"); - }); - - it("should stop timer after stopRecording is called", async () => { - wrapper = mountRecord(); - - await vi.advanceTimersByTimeAsync(5000); - expect(wrapper.text()).toContain("00:05"); - - const stopBtn = wrapper.find('[data-test="synthetics-record-stop-btn"]'); - await stopBtn.trigger("click"); - - await vi.advanceTimersByTimeAsync(5000); - // Timer should still show 00:05, not 00:10 - expect(wrapper.text()).toContain("00:05"); - }); - }); - - // ── Step Interactions ──────────────────────────────────────────────────── - describe("step interactions during recording", () => { - it("should remove step from list when step stub emits delete", async () => { - wrapper = mountRecord("https://app.test"); - - // Capture one step - await vi.advanceTimersByTimeAsync(600); - expect(wrapper.findAll(".journey-step-stub").length).toBe(1); - - // Simulate delete by emitting from the stubbed child component. - // Use findAllComponents with the stub object reference since inline stubs - // don't have a `name` property for findComponent({ name: ... }). - const stepComponents = wrapper.findAllComponents(BrowserJourneyStepStub); - stepComponents[0].vm.$emit("delete"); - await wrapper.vm.$nextTick(); - - expect(wrapper.findAll(".journey-step-stub").length).toBe(0); - }); - - // Note: duplicate emit is not tested here because the inline template expression - // `crypto.randomUUID()` called from `@duplicate` cannot resolve `crypto` in - // this test environment when triggered via $emit from a stub child component. - // The delete path (above) validates the parent-child event wiring pattern. - }); - - // ── Cleanup ────────────────────────────────────────────────────────────── - describe("cleanup", () => { - it("should clear timers on unmount", () => { - wrapper = mountRecord(); - - // Spy on clearInterval - const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); - - wrapper.unmount(); - - // The component should have called clearInterval - expect(clearIntervalSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/web/src/components/synthetics/journey/RecordJourney.vue b/web/src/components/synthetics/journey/RecordJourney.vue deleted file mode 100644 index 277536149c..0000000000 --- a/web/src/components/synthetics/journey/RecordJourney.vue +++ /dev/null @@ -1,193 +0,0 @@ - - - diff --git a/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.spec.ts b/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.spec.ts new file mode 100644 index 0000000000..4d08273c08 --- /dev/null +++ b/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.spec.ts @@ -0,0 +1,72 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import TestIdMisconfiguredNotice from "./TestIdMisconfiguredNotice.vue"; +import type { BrowserStep } from "@/types/synthetics"; +import en from "@/locales/languages/en-US.json"; + +const i18n = createI18n({ legacy: false, locale: "en-US", messages: { "en-US": en } }); + +const NOTICE = '[data-test="synthetics-journey-testid-misconfigured"]'; + +function step(kind: string | null): BrowserStep { + return { + id: "s1", + action: "click", + name: "Click", + locator: kind ? { candidates: [{ kind, value: "x" }], user_override: null } : undefined, + } as unknown as BrowserStep; +} + +function render(steps: BrowserStep[]) { + return mount(TestIdMisconfiguredNotice, { + props: { steps, testIdAttr: "data-testid" }, + global: { plugins: [i18n] }, + }); +} + +describe("TestIdMisconfiguredNotice", () => { + it("warns when no step in the recording produced a test_attribute candidate", () => { + // The silent failure: upstream's generator emits nothing at test-id rank + // when the configured attribute is not the one the app uses, and every step + // degrades to role/text/css without an error anywhere. + expect( + render([step("role"), step("css")]) + .find(NOTICE) + .exists(), + ).toBe(true); + }); + + it("names the attribute that was actually used, so the fix is obvious", () => { + expect(render([step("css")]).text()).toContain("data-testid"); + }); + + it("stays silent when any step found a test attribute", () => { + expect( + render([step("css"), step("test_attribute")]) + .find(NOTICE) + .exists(), + ).toBe(false); + }); + + it("stays silent for a journey with no element steps at all", () => { + // A navigate-only journey has nothing to find; zero test attributes there + // is not evidence of anything. + expect( + render([step(null)]) + .find(NOTICE) + .exists(), + ).toBe(false); + expect(render([]).find(NOTICE).exists()).toBe(false); + }); + + it("can be dismissed — a page may genuinely have no test attributes", () => { + const wrapper = render([step("css")]); + wrapper.find('[data-test="synthetics-journey-testid-misconfigured-dismiss"]').trigger("click"); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(NOTICE).exists()).toBe(false); + }); + }); +}); diff --git a/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.vue b/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.vue new file mode 100644 index 0000000000..bbbb0a41e0 --- /dev/null +++ b/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.vue @@ -0,0 +1,82 @@ + + + diff --git a/web/src/components/synthetics/journey/UpgradeJourneyBanner.spec.ts b/web/src/components/synthetics/journey/UpgradeJourneyBanner.spec.ts new file mode 100644 index 0000000000..ae4e340bf5 --- /dev/null +++ b/web/src/components/synthetics/journey/UpgradeJourneyBanner.spec.ts @@ -0,0 +1,83 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import type { BrowserStep } from "@/types/synthetics"; +import UpgradeJourneyBanner from "./UpgradeJourneyBanner.vue"; +import en from "@/locales/languages/en-US.json"; + +const i18n = createI18n({ + legacy: false, + locale: "en-US", + fallbackLocale: "en-US", + messages: { "en-US": en as Record }, +}); + +function step(overrides: Partial = {}): BrowserStep { + return { + id: "s1", + action: "click", + name: "Sign In", + selector: '[data-test="login-sign-in"]', + selectorType: "TestID", + code: "", + ...overrides, + }; +} + +function render(steps: BrowserStep[]) { + return mount(UpgradeJourneyBanner, { props: { steps }, global: { plugins: [i18n] } }); +} + +const test = (name: string) => `[data-test="${name}"]`; +const BANNER = test("synthetics-journey-upgrade-banner"); + +describe("UpgradeJourneyBanner", () => { + it("stays out of the way when there is nothing to upgrade", () => { + const alreadyV2 = step({ + locator: { candidates: [{ kind: "test_attribute", value: "#a" }], user_override: null }, + selector: undefined, + selectorType: undefined, + }); + expect(render([alreadyV2]).find(BANNER).exists()).toBe(false); + }); + + it("offers the upgrade when a journey still carries a hard sleep", () => { + const wrapper = render([step(), step({ id: "s2", action: "wait", timeout: 30000 })]); + expect(wrapper.find(BANNER).exists()).toBe(true); + }); + + // Dropping a step or removing a sleep is a real behaviour change. An author + // should read it before committing, not discover it from a diff. + it("previews every change before anything is applied", async () => { + const wrapper = render([step(), step({ id: "s2", action: "wait", timeout: 30000 })]); + expect(wrapper.find(test("synthetics-journey-upgrade-changes")).exists()).toBe(false); + + await wrapper.find(test("synthetics-journey-upgrade-preview-btn")).trigger("click"); + const changes = wrapper.find(test("synthetics-journey-upgrade-changes")); + expect(changes.exists()).toBe(true); + expect(changes.text()).toMatch(/settle budget/i); + }); + + it("emits the lifted journey, sleep converted and bundle created", async () => { + const wrapper = render([step(), step({ id: "s2", action: "wait", timeout: 30000 })]); + await wrapper.find(test("synthetics-journey-upgrade-apply-btn")).trigger("click"); + + const lifted = wrapper.emitted("upgrade")?.[0]?.[0] as BrowserStep[]; + expect(lifted.map((s) => s.id)).toEqual(["s1"]); + expect(lifted[0].locator?.candidates[0]).toEqual({ + kind: "test_attribute", + value: '[data-test="login-sign-in"]', + }); + expect(lifted[0].settle?.budget_ms).toBe(30000); + }); + + it("does not mutate the journey it was given", async () => { + const original = [step(), step({ id: "s2", action: "wait", timeout: 30000 })]; + const snapshot = JSON.stringify(original); + const wrapper = render(original); + await wrapper.find(test("synthetics-journey-upgrade-apply-btn")).trigger("click"); + expect(JSON.stringify(original)).toBe(snapshot); + }); +}); diff --git a/web/src/components/synthetics/journey/UpgradeJourneyBanner.vue b/web/src/components/synthetics/journey/UpgradeJourneyBanner.vue new file mode 100644 index 0000000000..448520f269 --- /dev/null +++ b/web/src/components/synthetics/journey/UpgradeJourneyBanner.vue @@ -0,0 +1,94 @@ + + + diff --git a/web/src/components/synthetics/journey/ZeroAssertionNotice.spec.ts b/web/src/components/synthetics/journey/ZeroAssertionNotice.spec.ts new file mode 100644 index 0000000000..16b015a88d --- /dev/null +++ b/web/src/components/synthetics/journey/ZeroAssertionNotice.spec.ts @@ -0,0 +1,65 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import type { BrowserStep } from "@/types/synthetics"; +import ZeroAssertionNotice from "./ZeroAssertionNotice.vue"; +import en from "@/locales/languages/en-US.json"; + +const i18n = createI18n({ + legacy: false, + locale: "en-US", + fallbackLocale: "en-US", + messages: { "en-US": en as Record }, +}); + +function step(overrides: Partial = {}): BrowserStep { + return { id: "s1", action: "click", name: "Sign In", code: "", ...overrides }; +} + +function render(steps: BrowserStep[]) { + return mount(ZeroAssertionNotice, { props: { steps }, global: { plugins: [i18n] } }); +} + +const test = (name: string) => `[data-test="${name}"]`; +const NOTICE = test("synthetics-journey-zero-assertion-notice"); + +describe("ZeroAssertionNotice", () => { + it("warns when a journey verifies nothing", () => { + expect(render([step()]).find(NOTICE).exists()).toBe(true); + }); + + it("stays quiet once the journey asserts something", () => { + expect( + render([step(), step({ id: "s2", action: "assert" })]) + .find(NOTICE) + .exists(), + ).toBe(false); + }); + + it("stays quiet for an empty journey — there is nothing to assert about yet", () => { + expect(render([]).find(NOTICE).exists()).toBe(false); + }); + + // P5.2.1 — the assertion is offered, never generated. A recorder cannot know + // what "correct" means for an application, so the step arrives with the kind + // chosen and the target left to the author. + it("adds an empty element_visible assertion rather than guessing one", async () => { + const wrapper = render([step()]); + await wrapper.find(test("synthetics-journey-add-assertion-btn")).trigger("click"); + + const added = wrapper.emitted("add-assertion")?.[0]?.[0] as BrowserStep; + expect(added.action).toBe("assert"); + expect(added.assertion).toEqual({ kind: "element_visible" }); + expect(added.selector).toBeUndefined(); + expect(added.locator).toBeUndefined(); + }); + + // An author who has decided is not told twice. + it("can be dismissed", async () => { + const wrapper = render([step()]); + await wrapper.find(test("synthetics-journey-zero-assertion-dismiss-btn")).trigger("click"); + expect(wrapper.find(NOTICE).exists()).toBe(false); + }); +}); diff --git a/web/src/components/synthetics/journey/ZeroAssertionNotice.vue b/web/src/components/synthetics/journey/ZeroAssertionNotice.vue new file mode 100644 index 0000000000..92582ff073 --- /dev/null +++ b/web/src/components/synthetics/journey/ZeroAssertionNotice.vue @@ -0,0 +1,88 @@ + + + diff --git a/web/src/components/synthetics/results/EvidencePanel.spec.ts b/web/src/components/synthetics/results/EvidencePanel.spec.ts new file mode 100644 index 0000000000..4156d2bc0e --- /dev/null +++ b/web/src/components/synthetics/results/EvidencePanel.spec.ts @@ -0,0 +1,255 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; + +import i18n from "@/locales"; +import EvidencePanel from "./EvidencePanel.vue"; +import { + foldEvidenceBundle, + isEvidenceAnomaly, + parseEvidenceNdjson, +} from "@/composables/synthetics/syntheticResultsSchema"; + +// ── Real bundle lines, copied from a live run ─────────────────────────────── +// +// `intro test (expect fail)`: 18 all-200 responses, the run failing on a locator +// timeout at step 20. That combination is the panel's whole reason for existing — +// `evidence_by_step` is EMPTY here because summarise() only emits for anomalous +// steps, so the record says "nothing to report" while the bundle says what the +// page was doing. +const NDJSON = [ + '{"ts":1785356285799,"kind":"response","method":"GET","url":"https://o2.example.dev/web/login","status":200,"resource_type":"document","initiated_ts":1785356285501,"duration_ms":298,"first_party":true,"step_id":"s19"}', + '{"ts":1785356286100,"kind":"response","method":"GET","url":"https://cdn.third-party.io/a.js","status":200,"resource_type":"script","initiated_ts":1785356285900,"duration_ms":80,"first_party":false,"step_id":"s19"}', + '{"ts":1785356286500,"kind":"response","method":"GET","url":"https://o2.example.dev/api/streams","status":500,"resource_type":"xhr","initiated_ts":1785356286200,"duration_ms":300,"first_party":true,"step_id":"s19"}', + '{"ts":1785356286600,"kind":"console","level":"error","text":"Uncaught TypeError: e.map is not a function","step_id":"s19"}', +].join("\n"); + +const STEP_DEFS = new Map([ + ["s19", { name: "Navigate to /web/login", selector: null }], + ["fa1", { name: 'Assert visible [data-test="element-that-never-exists"]', selector: null }], +]); + +describe("evidence bundle parsing", () => { + it("parses NDJSON line by line, not as a JSON document", () => { + // JSON.parse on the whole payload throws — which is also why the download + // button must not be labelled "JSON". + expect(() => JSON.parse(NDJSON)).toThrow(); + expect(parseEvidenceNdjson(NDJSON)).toHaveLength(4); + }); + + it("drops only the malformed line, never the panel", () => { + // A bundle truncated at the cap ends mid-line by construction, so this is + // the expected case at the limit rather than corruption. + const withJunk = `${NDJSON}\n{"ts":1,"kind":"response"`; + expect(parseEvidenceNdjson(withJunk)).toHaveLength(4); + expect(parseEvidenceNdjson("")).toEqual([]); + expect(parseEvidenceNdjson("\n\n \n")).toEqual([]); + }); + + it("keeps both timestamps distinct", () => { + // Work begun in step 9 completes during step 10; collapsing ts and + // initiated_ts would hide that rather than show it. + const [first] = parseEvidenceNdjson(NDJSON); + expect(first.ts).toBe(1785356285799); + expect(first.initiatedTs).toBe(1785356285501); + }); + + it("treats a missing first_party as first-party, not third", () => { + const [e] = parseEvidenceNdjson('{"ts":1,"kind":"response","status":200}'); + expect(e.firstParty).toBe(true); + }); +}); + +describe("anomaly classification", () => { + it("counts only what an engineer would call a problem", () => { + const mk = (o: any) => parseEvidenceNdjson(JSON.stringify({ ts: 1, ...o }))[0]; + expect(isEvidenceAnomaly(mk({ kind: "response", status: 200 }))).toBe(false); + expect(isEvidenceAnomaly(mk({ kind: "response", status: 302 }))).toBe(false); + expect(isEvidenceAnomaly(mk({ kind: "response", status: 404 }))).toBe(true); + expect(isEvidenceAnomaly(mk({ kind: "requestfailed" }))).toBe(true); + expect(isEvidenceAnomaly(mk({ kind: "pageerror" }))).toBe(true); + // A console *warning* is not an anomaly; only an error is. + expect(isEvidenceAnomaly(mk({ kind: "console", level: "warning" }))).toBe(false); + expect(isEvidenceAnomaly(mk({ kind: "console", level: "error" }))).toBe(true); + }); +}); + +describe("evidence grouping", () => { + const fold = (text = NDJSON) => foldEvidenceBundle(parseEvidenceNdjson(text), STEP_DEFS); + + it("groups by kind, not by step", () => { + // Step grouping reads well in a wireframe and degenerates on real data: a + // live 158-event bundle held two distinct step_ids, so it produced one + // section of 136 and one of 22 and told the reader nothing. + expect(fold().groups.map((g) => g.kind)).toEqual(["console", "network"]); + }); + + it("orders groups by severity, not by volume", () => { + // 153 responses must not bury one page error. + const text = [ + '{"ts":5,"kind":"response","status":200,"initiated_ts":5}', + '{"ts":1,"kind":"pageerror","message":"boom"}', + '{"ts":2,"kind":"requestfailed","url":"https://x/y"}', + '{"ts":3,"kind":"console","level":"error","text":"bad"}', + ].join("\n"); + expect(fold(text).groups.map((g) => g.kind)).toEqual([ + "pageErrors", + "requestsFailed", + "console", + "network", + ]); + }); + + it("orders events within a group by when they were initiated", () => { + const g = fold().groups.find((x) => x.kind === "network")!; + expect(g.events.map((e) => e.initiatedTs)).toEqual([ + 1785356285501, 1785356285900, 1785356286200, + ]); + }); + + it("flags a group that contains an anomaly", () => { + const groups = fold().groups; + // network holds the 502, console holds the error. + expect(groups.find((g) => g.kind === "network")!.hasAnomaly).toBe(true); + expect(groups.find((g) => g.kind === "console")!.hasAnomaly).toBe(true); + // All-200 network is not flagged. + const clean = fold('{"ts":1,"kind":"response","status":200}'); + expect(clean.groups[0].hasAnomaly).toBe(false); + }); + + it("resolves the step name onto each row, falling back to the id", () => { + // Attribution is kept; it just moved off the grouping axis. + const g = fold().groups.find((x) => x.kind === "network")!; + expect(g.events[0].stepName).toBe("Navigate to /web/login"); + const unknown = fold('{"ts":1,"kind":"response","status":200,"step_id":"s99"}'); + expect(unknown.groups[0].events[0].stepName).toBe("s99"); + }); + + it("leaves an unattributed event's step name null rather than guessing", () => { + const b = fold('{"ts":1,"kind":"pageerror","message":"boom"}'); + expect(b.groups[0].events[0].stepName).toBeNull(); + }); + + it("counts each anomaly kind separately", () => { + expect(fold().counts).toMatchObject({ + all: 4, + consoleErrors: 1, + nonNon2xx: 1, + pageErrors: 0, + requestsFailed: 0, + }); + }); + + it("reports truncation from either the record or a truncation event", () => { + expect(fold().truncated).toBe(false); + expect(foldEvidenceBundle(parseEvidenceNdjson(NDJSON), STEP_DEFS, true).truncated).toBe(true); + expect( + foldEvidenceBundle(parseEvidenceNdjson('{"ts":1,"kind":"truncation"}'), STEP_DEFS).truncated, + ).toBe(true); + }); +}); + +describe("EvidencePanel", () => { + const mountPanel = (props: Record = {}) => + mount(EvidencePanel, { + props: { + evidenceKey: "synthetics/org/mon/2026/07/29/RUN/EXEC/attempt-1-evidence.ndjson", + resolveUrl: (k: string) => `/artifact?key=${k}`, + stepDefs: STEP_DEFS, + ...props, + }, + global: { plugins: [i18n] }, + }); + + beforeEach(() => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + status: 200, + statusText: "OK", + text: async () => NDJSON, + })) as any; + }); + + it("fetches the bundle and renders a section per kind", async () => { + const w = mountPanel(); + await flushPromises(); + expect(w.find('[data-test="synthetics-evidence-panel"]').exists()).toBe(true); + expect(w.find('[data-test="synthetics-evidence-group-network"]').exists()).toBe(true); + expect(w.find('[data-test="synthetics-evidence-group-console"]').exists()).toBe(true); + // No page errors in this bundle, so no empty section header for them. + expect(w.find('[data-test="synthetics-evidence-group-pageErrors"]').exists()).toBe(false); + }); + + it("shows which step each row belongs to", async () => { + const w = mountPanel(); + await flushPromises(); + expect(w.find('[data-test="synthetics-evidence-row-step"]').text()).toContain( + "Navigate to /web/login", + ); + }); + + it("refetches when the attempt changes, so bundles never cross labels", async () => { + const w = mountPanel(); + await flushPromises(); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + await w.setProps({ evidenceKey: "…/evidence.ndjson" }); + await flushPromises(); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect((globalThis.fetch as any).mock.calls[1][0]).toContain("/evidence.ndjson"); + }); + + it("keeps zero-count chips visible rather than hiding them", async () => { + const w = mountPanel(); + await flushPromises(); + // A hidden zero is indistinguishable from a chip that does not exist, and + // "no page errors" is information. + expect(w.find('[data-test="synthetics-evidence-chip-pageErrors"]').exists()).toBe(true); + expect(w.find('[data-test="synthetics-evidence-chip-pageErrors"]').text()).toContain("0"); + }); + + it("reports a failed fetch instead of rendering a quiet run", async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: false, + status: 403, + statusText: "Forbidden", + text: async () => "", + })) as any; + const w = mountPanel(); + await flushPromises(); + expect(w.find('[data-test="synthetics-evidence-error"]').exists()).toBe(true); + expect(w.find('[data-test="synthetics-evidence-panel"]').text()).toContain("403"); + }); + + it("distinguishes capture-off from not-kept from absent", async () => { + const off = mountPanel({ evidenceKey: null, captureOff: true }); + await flushPromises(); + expect(off.find('[data-test="synthetics-evidence-empty"]').text()).toContain("capture is off"); + + const passed = mountPanel({ evidenceKey: null, runPassed: true }); + await flushPromises(); + expect(passed.find('[data-test="synthetics-evidence-empty"]').text()).toContain( + "failed runs only", + ); + + const none = mountPanel({ evidenceKey: null }); + await flushPromises(); + expect(none.find('[data-test="synthetics-evidence-empty"]').text()).toContain( + "No evidence bundle", + ); + // Never fetch when there is no key. + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("states truncation rather than showing a quietly short list", async () => { + const w = mountPanel({ recordTruncated: true }); + await flushPromises(); + expect(w.find('[data-test="synthetics-evidence-truncated"]').exists()).toBe(true); + }); + + it("labels the download as NDJSON", async () => { + const w = mountPanel(); + await flushPromises(); + expect(w.find('[data-test="synthetics-evidence-download"]').text()).toContain(".ndjson"); + }); +}); diff --git a/web/src/components/synthetics/results/EvidencePanel.vue b/web/src/components/synthetics/results/EvidencePanel.vue new file mode 100644 index 0000000000..dc8aef0f54 --- /dev/null +++ b/web/src/components/synthetics/results/EvidencePanel.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/web/src/composables/synthetics/attemptViews.spec.ts b/web/src/composables/synthetics/attemptViews.spec.ts new file mode 100644 index 0000000000..63b3c58adb --- /dev/null +++ b/web/src/composables/synthetics/attemptViews.spec.ts @@ -0,0 +1,231 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, it, expect } from "vitest"; + +import { buildAttemptViews, mapRunDetail } from "./syntheticResultsSchema"; + +// ── Driven by a real ingested row, not a hand-tuned fixture ───────────────── +// +// The attempts view was built twice and shipped twice without appearing: once +// because the run-detail query never selected `attempts` or `retry_history`, and +// once because `mapRetryHistory` guarded on `Array.isArray` while the search API +// returns blob columns as JSON STRINGS — so it returned [] for every real record. +// `mapRunDetail` also read `rawHit.attempt`, a field no record has ever carried. +// +// All three were invisible to a test built around a purpose-made object and were +// only caught from a screenshot. So the input here is the shape the stream +// actually returns, column aliases and all, pushed through the real mapper. + +/** One execution of `intro test (expect fail)`, retries=1, as ingested. */ +function ingestedRow(over: Record = {}) { + const base = "synthetics/default/3HAzRCdLOfBKPUm6Rg4Pvr9Ocig/2026/07/29/RUN/EXEC/"; + return { + ts: 1785356358235000, + status: "failed", + duration: 58341, + scheduled_ts: 1785356234814690, + started_ts: 1785356236773000, + init_ms: 118, + attempts: 2, + total_attempt_ms: 121229, + run_id: "RUN", + execution_id: "EXEC", + synthetics_name: "intro test (expect fail)", + trace_key: `${base}attempt-1-trace.zip`, + evidence_key: `${base}attempt-1-evidence.ndjson`, + // The deciding attempt's steps, which is what `buildAttemptViews` substitutes + // for its compact timeline. The probe stamps `screenshot_key` here too, under + // the deciding attempt's own prefix. + last_attempt_steps: JSON.stringify([ + { + step_id: "s1", + status: "passed", + duration_ms: 5065, + screenshot_key: `${base}attempt-1-screenshot-s1.png`, + }, + { + step_id: "fa1", + status: "failed", + duration_ms: 5005, + error: "Timeout 5000ms exceeded", + screenshot_key: `${base}attempt-1-screenshot-fa1.png`, + }, + ]), + // The API returns this as a string, not an array. Passing an array here + // would test a shape production never produces. + retry_history: JSON.stringify([ + { + attempt: 0, + status: "failed", + response_time_ms: 57795, + init_ms: 188, + steps: [ + { + step_id: "s1", + status: "passed", + duration_ms: 5046, + screenshot_key: `${base}screenshot-s1.png`, + }, + { + step_id: "fa1", + status: "failed", + duration_ms: 5003, + screenshot_key: `${base}screenshot-fa1.png`, + }, + ], + failure_detail: { + step_id: "fa1", + step_name: "Assert visible", + step_index: 20, + error: "Timeout", + }, + artifacts: { + screenshot_refs: [{ step_id: "fa1", key: `${base}screenshot-fa1.png` }], + trace_ref: `${base}trace.zip`, + evidence_ref: `${base}evidence.ndjson`, + }, + }, + { + attempt: 1, + status: "failed", + response_time_ms: 58341, + init_ms: 118, + steps: [ + { + step_id: "s1", + status: "passed", + duration_ms: 5065, + screenshot_key: `${base}attempt-1-screenshot-s1.png`, + }, + { + step_id: "fa1", + status: "failed", + duration_ms: 5005, + screenshot_key: `${base}attempt-1-screenshot-fa1.png`, + }, + ], + failure_detail: { + step_id: "fa1", + step_name: "Assert visible", + step_index: 20, + error: "Timeout", + }, + artifacts: { + screenshot_refs: [{ step_id: "fa1", key: `${base}attempt-1-screenshot-fa1.png` }], + trace_ref: `${base}attempt-1-trace.zip`, + evidence_ref: `${base}attempt-1-evidence.ndjson`, + }, + }, + ]), + ...over, + }; +} + +const viewsFor = (over: Record = {}) => + buildAttemptViews(mapRunDetail(ingestedRow(over))!); + +describe("attempt views", () => { + it("yields one view per attempt, so the selector has something to select", () => { + // A single-attempt run yields exactly one view; the caller hides the + // selector rather than rendering a control with one option. + expect(viewsFor()).toHaveLength(2); + expect(viewsFor({ attempts: 1, retry_history: "" })).toHaveLength(1); + }); + + it("marks the last attempt as the one that decided the run", () => { + const views = viewsFor(); + expect(views.map((v) => v.decided)).toEqual([false, true]); + // `decided` describes the ATTEMPT, not the run: on a flaky run the deciding + // attempt passed while the run reports `warning`. + expect(views[1].compact).toBe(false); + expect(views[0].compact).toBe(true); + }); + + it("keeps each attempt's own artifacts rather than the survivor's", () => { + const views = viewsFor(); + // The regression this guards: a superseded attempt's screenshots live under + // an `attempt-N-` key, and falling back to the record's would show the + // deciding attempt's pixels under the failing attempt's label. + expect(views[0].screenshotKeys.get("fa1")).toContain("/screenshot-fa1.png"); + expect(views[0].traceKey).toContain("/trace.zip"); + expect(views[0].traceKey).not.toContain("attempt-1-"); + expect(views[1].screenshotKeys.get("fa1")).toContain("attempt-1-screenshot-fa1.png"); + expect(views[1].traceKey).toContain("attempt-1-trace.zip"); + }); + + it("reports each attempt's own duration, not a sum of step durations", () => { + // Summing steps misses launch, settle waits and the navigation a step + // triggers — most of the wall clock on a real journey. + const views = viewsFor(); + expect(views[0].durationMs).toBe(57795); + expect(views[1].durationMs).toBe(58341); + }); + + it("counts attempts from the field the record actually carries", () => { + // `mapRunDetail` read `rawHit.attempt`, which no record has; the count was 0 + // on every run and the retry chip never appeared. + expect(mapRunDetail(ingestedRow())!.attempts).toBe(2); + // And falls back to the history length when `attempts` is absent. + expect(mapRunDetail(ingestedRow({ attempts: 0 }))!.attempts).toBe(2); + }); +}); + +// ── Each attempt keeps its own screenshots and its own evidence bundle ─────── + +describe("per-attempt artifacts", () => { + it("normalises the compact timeline's step statuses", () => { + // The probe writes `passed`/`failed` on the compact timeline while + // `StepExecution` declares `ok`/`fail`, and every consumer tests for `fail`. + // Passed through raw, a superseded attempt's FAILING step rendered as a + // pass — a green tick on the step that actually broke. + const [superseded] = viewsFor(); + expect(superseded.steps.map((s) => s.status)).toEqual(["ok", "fail"]); + }); + + it("keeps `skipped` distinct from `fail`", () => { + // An `optional` step exists because it may not be there; collapsing it to + // `fail` reports a correctly-skipped step as a broken one. + const rows = viewsFor({ + retry_history: JSON.stringify([ + { + attempt: 0, + status: "failed", + steps: [{ step_id: "opt", status: "skipped", duration_ms: 1 }], + }, + { attempt: 1, status: "failed", steps: [] }, + ]), + }); + expect(rows[0].steps[0].status).toBe("skipped"); + }); + + it("resolves screenshots per attempt, from that attempt's own keys", () => { + const [a0, a1] = viewsFor(); + // Attempt 0 keeps the bare key; retries are `attempt-N-` prefixed. Falling + // back to the record's key would show the deciding attempt's pixels under + // the failing attempt's label. + expect(a0.steps.find((s) => s.step_id === "fa1")!.screenshot_key).toContain( + "/screenshot-fa1.png", + ); + expect(a0.steps.find((s) => s.step_id === "fa1")!.screenshot_key).not.toContain("attempt-1-"); + expect(a1.steps.find((s) => s.step_id === "fa1")!.screenshot_key).toContain( + "attempt-1-screenshot-fa1.png", + ); + // And the same via the refs map the step table reads. + expect(a0.screenshotKeys.get("fa1")).not.toContain("attempt-1-"); + expect(a1.screenshotKeys.get("fa1")).toContain("attempt-1-"); + }); + + it("gives every attempt its own evidence bundle and trace", () => { + const [a0, a1] = viewsFor(); + expect(a0.evidenceKey).toBe( + "…/evidence.ndjson".replace( + "…/", + a0.evidenceKey!.slice(0, a0.evidenceKey!.lastIndexOf("/") + 1), + ), + ); + expect(a0.evidenceKey).not.toContain("attempt-1-"); + expect(a1.evidenceKey).toContain("attempt-1-evidence.ndjson"); + expect(a0.traceKey).not.toContain("attempt-1-"); + expect(a1.traceKey).toContain("attempt-1-trace.zip"); + }); +}); diff --git a/web/src/composables/synthetics/syntheticResultsSchema.spec.ts b/web/src/composables/synthetics/syntheticResultsSchema.spec.ts index 17bf23827f..8682605512 100644 --- a/web/src/composables/synthetics/syntheticResultsSchema.spec.ts +++ b/web/src/composables/synthetics/syntheticResultsSchema.spec.ts @@ -15,6 +15,7 @@ import { describe, expect, it } from "vitest"; import { + mapRunDetail, SYNTHETIC_FIELDS, SYNTHETIC_RESULTS_STREAM, STATUS_VALUES, @@ -25,6 +26,13 @@ import { buildLastRunSql, buildRunsSql, buildRunsWithStepsSql, + buildStepDefsSql, + buildRetryAttributionSql, + buildAttemptViews, + foldRetryAttribution, + foldStepDefs, + splitDelimited, + STATUS_REASON, deviceIconName, deviceLabel, mapHistogram, @@ -232,11 +240,59 @@ describe("buildRunsWithStepsSql", () => { const sql = buildRunsWithStepsSql("mon-1", 500); expect(sql).toContain(`FROM "${SYNTHETIC_RESULTS_STREAM}"`); expect(sql).toContain("last_attempt_steps"); - expect(sql).toContain("recorded_steps"); expect(sql).toContain("retry_history"); expect(sql).toContain("attempts"); expect(sql).toContain("LIMIT 500"); }); + + it("should NOT select recorded_steps on the wide tally query", () => { + // ~4 KB per row, near-identical across rows of one config version. Selecting + // it on 5000 rows was roughly 60% of the panel's payload; the definitions + // come from buildStepDefsSql over a bounded subset instead. + expect(buildRunsWithStepsSql("mon-1", 5000)).not.toContain("recorded_steps"); + }); + + it("should fetch step definitions from a bounded row subset", () => { + const sql = buildStepDefsSql("mon-1", 100); + expect(sql).toContain("recorded_steps"); + expect(sql).toContain("LIMIT 100"); + expect(sql).toContain("ORDER BY"); + // Only the blob — nothing else is needed to build the lookup. + expect(sql).not.toContain("last_attempt_steps"); + }); +}); + +describe("foldStepDefs", () => { + const row = (steps: unknown[]) => ({ recorded_steps: JSON.stringify(steps) }); + + it("should build a step_id keyed lookup", () => { + const defs = foldStepDefs([ + row([ + { id: "s1", name: "Open page" }, + { id: "s2", name: "Click login" }, + ]), + ]); + expect(defs.get("s1")?.name).toBe("Open page"); + expect(defs.get("s2")?.name).toBe("Click login"); + }); + + it("should prefer the newest definition when a step was renamed", () => { + // Rows arrive newest-first, so the first definition seen for an id wins — + // a renamed step shows its current name while older rows still resolve. + const defs = foldStepDefs([ + row([{ id: "s1", name: "Click sign in" }]), // newer + row([{ id: "s1", name: "Click login" }]), // older + ]); + expect(defs.get("s1")?.name).toBe("Click sign in"); + }); + + it("should fall back to the id when a definition has no name", () => { + expect(foldStepDefs([row([{ id: "s9" }])]).get("s9")?.name).toBe("s9"); + }); + + it("should tolerate rows with no recorded_steps", () => { + expect(foldStepDefs([{}, { recorded_steps: "" }]).size).toBe(0); + }); }); describe("aggregateStepStats", () => { @@ -478,3 +534,373 @@ describe("deviceLabel", () => { expect(deviceLabel("")).toBe(""); }); }); + +describe("mapRunDetail — evidence the probe already writes (Phase 4)", () => { + function hit(overrides: Record = {}): Record { + return { + ts: 1_700_000_000_500_000, + engine: "chromium", + location: "us-east-1", + device: "desktop", + run_id: "run-1", + execution_id: "exec-1", + recorded_steps: JSON.stringify([{ id: "s1", name: "Sign in" }]), + last_attempt_steps: JSON.stringify([ + { step_id: "s1", status: "ok", duration_ms: 10, error: "" }, + ]), + ...overrides, + }; + } + + it("keeps `skipped` as skipped instead of reporting it as a failure", () => { + // An `optional` step exists precisely because it may not be there — a + // cookie banner, a one-time popup. Collapsing it to `fail` reported a + // correctly-skipped step as broken, the opposite of what the feature is for. + const detail = mapRunDetail( + hit({ + last_attempt_steps: JSON.stringify([ + { step_id: "s1", status: "ok", duration_ms: 5 }, + { step_id: "s2", status: "skipped", duration_ms: 1 }, + { step_id: "s3", status: "failed", duration_ms: 9 }, + ]), + }), + ); + expect(detail.lastAttemptSteps.map((s) => s.status)).toEqual(["ok", "skipped", "fail"]); + }); + + it("maps retry history instead of discarding it", () => { + // The probe writes retry_history on every failed run; the mapper hardcoded + // an empty array, so no component could ever read it. A step that failed + // once and passed next attempt is transient by definition. + const detail = mapRunDetail( + hit({ + retry_history: [ + { + attempt: 0, + steps: [ + { step_id: "s1", status: "passed", duration_ms: 100 }, + { step_id: "s2", status: "failed", duration_ms: 400 }, + ], + }, + ], + }), + ); + expect(detail.retryHistory).toHaveLength(1); + expect(detail.retryHistory[0].attempt).toBe(0); + // Derived, not invented: an entry exists only because that attempt failed. + expect(detail.retryHistory[0].status).toBe("failed"); + expect(detail.retryHistory[0].durationMs).toBe(500); + expect(detail.retryHistory[0].failedStep).toBe("s2"); + }); + + it("maps the seven items of failure_detail", () => { + const detail = mapRunDetail( + hit({ + failure_detail: { + step_id: "s2", + step_name: "Profile visible", + step_index: 2, + error: "locator.waitFor: Timeout 60000ms exceeded", + candidates_tried: [ + { kind: "test_attribute", value: '[data-test="x"]', outcome: "not_found" }, + { kind: "role", value: "internal:role=button", outcome: "matched" }, + ], + settle_signals: [ + { + kind: "response", + signal: "response matching **/auth/login", + status: "stale", + required: false, + waited_ms: 30000, + }, + ], + settle_ms: 41000, + observed_duration_ms: 2300, + screenshot_key: "k/shot.png", + trace_key: "k/trace.zip", + }, + }), + ); + + const fd = detail.failureDetail!; + expect(fd.stepName).toBe("Profile visible"); + expect(fd.stepIndex).toBe(2); + expect(fd.error).toContain("Timeout 60000ms"); + // Item 3 — which candidate matched answers "locator rot?" mechanically. + expect(fd.candidatesTried.map((c) => c.outcome)).toEqual(["not_found", "matched"]); + // Item 4 — a stale signal is the strongest application-is-at-fault + // indicator already on the record, and it was invisible. + expect(fd.settleSignals[0].status).toBe("stale"); + expect(fd.settleSignals[0].waitedMs).toBe(30000); + // Item 5 — settled in 2.3s when recorded, 41s today. + expect(fd.settleMs).toBe(41000); + expect(fd.observedDurationMs).toBe(2300); + expect(fd.screenshotKey).toBe("k/shot.png"); + expect(fd.traceKey).toBe("k/trace.zip"); + }); + + it("returns null failure detail on a passing run", () => { + expect(mapRunDetail(hit()).failureDetail).toBeNull(); + }); + + it("degrades rather than throwing on a record written before these fields", () => { + // Backwards compatibility: records predating the probe change carry none of + // this, and must render as they did before rather than break the view. + const detail = mapRunDetail(hit({ retry_history: undefined, failure_detail: undefined })); + expect(detail.retryHistory).toEqual([]); + expect(detail.failureDetail).toBeNull(); + }); +}); + +// ── C3 · flaky and degraded are different failures ─────────────────────────── + +describe("KPI: warning is two unrelated things", () => { + it("splits flaky from degraded when status_reason is in the schema", () => { + const sql = buildKpiSql("mon-1", true, true); + expect(sql).toContain("as flaky_runs"); + expect(sql).toContain("as degraded_runs"); + // Both clauses hang off the scan the query already performs. + expect(sql.match(/FROM/g)).toHaveLength(1); + }); + + it("omits both clauses when the field is absent from the schema", () => { + // The search API rejects a query naming a field the stream doesn't have, + // which would take the whole KPI panel down rather than one tile. + const sql = buildKpiSql("mon-1", true, false); + expect(sql).not.toContain("status_reason"); + }); + + it("counts flaky and degraded separately, against the execution denominator", () => { + const kpi = mapKpi( + { + total_runs: 100, + passed_runs: 80, + warning_runs: 12, + failed_runs: 5, + error_runs: 3, + flaky_runs: 4, + degraded_runs: 8, + }, + null, + ); + // A TLS check inside its warning window is `warning` on every single run. + // Folded together, it reported as ~100% flaky forever. + expect(kpi.flakyExecutions).toBe(4); + expect(kpi.degradedExecutions).toBe(8); + // D4 — the denominator is executions, the grain of totalRuns. + expect(kpi.totalRuns).toBe(100); + }); +}); + +// ── C7 · the flaky column without the 5000-row blob fetch ──────────────────── + +describe("retry attribution", () => { + it("scans only the rows that actually retried", () => { + const sql = buildRetryAttributionSql("mon-1"); + expect(sql).toContain("attempts > 1"); + expect(sql).toContain("retry_step_ids"); + // The point of the column is that no blob is read. + expect(sql).not.toContain("retry_history"); + expect(sql).not.toContain("last_attempt_steps"); + }); + + it("splits the delimited form back without empty members", () => { + // The probe wraps in leading/trailing commas so LIKE '%,s2,%' cannot also + // match s20. Splitting has to drop the segments that wrapping creates. + expect(splitDelimited(",s2,s20,")).toEqual(["s2", "s20"]); + expect(splitDelimited("")).toEqual([]); + expect(splitDelimited(undefined)).toEqual([]); + }); + + it("reads recovery from the verdict, not from the row's presence", () => { + // D2 — attribution is written on any retried execution. A run that retried + // three times and still failed is attributed too, and must not be counted + // as a step that recovers. + const summary = foldRetryAttribution([ + { + execution_id: "e1", + status: STATUS_VALUES.warning, + status_reason: STATUS_REASON.flaky, + retry_step_ids: ",s3,", + retry_error_classes: ",timeout,", + }, + { + execution_id: "e2", + status: STATUS_VALUES.failed, + status_reason: "", + retry_step_ids: ",s3,", + retry_error_classes: ",timeout,", + retry_consistent: true, + }, + ]); + expect(summary.retriedExecutions).toBe(2); + expect(summary.byStep.get("s3")).toEqual({ retriedExecutions: 2, flakyExecutions: 1 }); + expect(summary.byErrorClass.get("timeout")).toBe(2); + expect(summary.consistentFailures).toBe(1); + expect(summary.byExecution.get("e1")).toEqual(new Set(["s3"])); + }); + + it("treats an absent retry_consistent as unknown, never as false", () => { + // D1 — the column is deliberately absent below two failing attempts. + // Counting absent as `false` would report every recovered run as + // non-deterministic, which is the opposite of what happened. + const summary = foldRetryAttribution([ + { + execution_id: "e1", + status: STATUS_VALUES.warning, + status_reason: STATUS_REASON.flaky, + retry_step_ids: ",s1,", + }, + ]); + expect(summary.consistentFailures).toBe(0); + expect(summary.retriedExecutions).toBe(1); + }); + + it("drives the flaky tally without any retry_history being present", () => { + const attribution = foldRetryAttribution([ + { + execution_id: "ex-1", + status: STATUS_VALUES.warning, + status_reason: STATUS_REASON.flaky, + retry_step_ids: ",s1,", + }, + ]); + const stats = aggregateStepStats( + [ + { + ts: 1_700_000_000_000_000, + execution_id: "ex-1", + run_id: "r1", + attempts: 2, + // No retry_history column at all — that is the saving. + last_attempt_steps: JSON.stringify([ + { step_id: "s1", status: "ok", duration_ms: 120 }, + { step_id: "s2", status: "ok", duration_ms: 80 }, + ]), + }, + ], + 1_699_000_000_000_000, + 1_701_000_000_000_000, + new Map([ + ["s1", { name: "Sign in", selector: ".btn" }], + ["s2", { name: "Dashboard", selector: null }], + ]), + attribution, + ); + const flaky = stats.flakySteps.find((f) => f.stepName === "Sign in"); + expect(flaky, "s1 failed on attempt 0 and passed on attempt 1").toBeTruthy(); + expect(stats.flakySteps.find((f) => f.stepName === "Dashboard")).toBeUndefined(); + }); +}); + +// ── C2 · one uniform attempts list ─────────────────────────────────────────── + +describe("attempt views", () => { + const detail = (over: Record = {}) => + mapRunDetail({ + ts: 1_700_000_000_000_000, + status: STATUS_VALUES.warning, + duration: 1200, + execution_id: "ex-1", + run_id: "r1", + attempts: 2, + last_attempt_steps: JSON.stringify([{ step_id: "s1", status: "ok", duration_ms: 120 }]), + trace_key: "traces/final.zip", + ...over, + })!; + + it("marks the last attempt as the deciding one and gives it the full detail", () => { + const views = buildAttemptViews( + detail({ + retry_history: [ + { + attempt: 0, + status: "failed", + response_time_ms: 3400, + steps: [{ step_id: "s1", status: "failed", duration_ms: 3000 }], + artifacts: { trace_ref: "traces/attempt-1.zip" }, + }, + { attempt: 1, status: "passed", response_time_ms: 1200, steps: [] }, + ], + }), + ); + expect(views).toHaveLength(2); + expect(views[0]).toMatchObject({ decided: false, compact: true, status: "failed" }); + // A passing final attempt used to be reported as failed: the mapper + // hard-coded the status on the reasoning that entries only exist for + // failures. On a flaky run that is the one attempt that passed. + expect(views[1]).toMatchObject({ decided: true, compact: false, status: "passed" }); + // The deciding attempt is what the record's top-level fields describe. + expect(views[1].steps).toHaveLength(1); + expect(views[1].traceKey).toBe("traces/final.zip"); + // A superseded attempt keeps its OWN artifacts, not the survivor's. + expect(views[0].traceKey).toBe("traces/attempt-1.zip"); + }); + + it("uses the probe's own duration rather than summing step durations", () => { + // Summing steps misses everything between them — launch, settle waits, the + // navigation a step triggers — which on a real journey is most of the time. + const views = buildAttemptViews( + detail({ + retry_history: [ + { + attempt: 0, + status: "failed", + response_time_ms: 3400, + steps: [{ step_id: "s1", status: "failed", duration_ms: 120 }], + }, + { attempt: 1, status: "passed", response_time_ms: 1200, steps: [] }, + ], + }), + ); + expect(views[0].durationMs).toBe(3400); + }); + + it("shows a single attempt for a run that never retried", () => { + const views = buildAttemptViews(detail({ retry_history: [], attempts: 1 })); + expect(views).toHaveLength(1); + expect(views[0].decided).toBe(true); + }); + + it("counts attempts from the field the probe actually writes", () => { + // The mapper read `attempt`, which no record has ever carried, so the count + // was 0 on every run and the retry chip never appeared. + expect(detail({ attempts: 3 }).attempts).toBe(3); + }); +}); + +// ── C4/C5 · the two costs inside one duration ──────────────────────────────── + +describe("run timing breakdown", () => { + it("separates queue delay from run duration", () => { + const run = mapRun({ + ts: 1_700_000_002_200_000, + scheduled_ts: 1_700_000_000_000_000, + started_ts: 1_700_000_001_000_000, + duration: 1200, + init_ms: 900, + status: STATUS_VALUES.passed, + }); + expect(run.queueDelayMs).toBe(1000); + // init is INSIDE duration — subtract it, never add it. + expect(run.initMs).toBe(900); + expect(run.durationMs).toBe(1200); + }); + + it("reports an unknown queue delay as null, not as zero", () => { + // Rendering an unknown as 0 ms claims the scheduler was perfect on every + // record written before started_ts existed. + const run = mapRun({ ts: 1_700_000_002_200_000, scheduled_ts: 1_700_000_000_000_000 }); + expect(run.queueDelayMs).toBeNull(); + }); + + it("never reports a negative delay", () => { + // A start before the schedule is a clock artefact, not early execution. + const run = mapRun({ + ts: 1_700_000_002_200_000, + scheduled_ts: 1_700_000_001_000_000, + started_ts: 1_700_000_000_000_000, + }); + expect(run.queueDelayMs).toBe(0); + }); +}); diff --git a/web/src/composables/synthetics/syntheticResultsSchema.ts b/web/src/composables/synthetics/syntheticResultsSchema.ts index 02a05a6ebd..1e590c5195 100644 --- a/web/src/composables/synthetics/syntheticResultsSchema.ts +++ b/web/src/composables/synthetics/syntheticResultsSchema.ts @@ -49,6 +49,33 @@ export const STATUS_VALUES = { error: "error", } as const; +/** + * Why a run is `warning`. Written by both probes alongside `status: "warning"` + * and by nothing else. + * + * `warning` is produced by two unrelated layers — the retry loop (a run that + * failed and then passed) and a checker reporting a reachable-but-degrading + * target. Without this discriminator a flaky rate of `warning / total` reports + * a TLS check with a soon-expiring certificate as ~100% flaky forever, and "do + * not alert on warning" silences certificate-expiry alerts. + */ +export const STATUS_REASON = { + flaky: "flaky", + certExpiring: "cert_expiring", + sftpDegraded: "sftp_degraded", +} as const; + +/** + * Why an `error` record exists. `dispatch` — the probe was never invoked, and + * the control plane wrote the record. `probe` — the probe ran and crashed. + * Two structurally different records used to arrive under one status, + * distinguishable only by sniffing which fields happened to be present. + */ +export const ERROR_SOURCE = { + dispatch: "dispatch", + probe: "probe", +} as const; + // ── Device display helpers ───────────────────────────────────────────────── /** @@ -92,6 +119,23 @@ export interface SyntheticKpi { totalRuns: number; /** Count of runs that had at least one retry (attempts > 1). */ retriedRuns: number; + /** + * Executions that failed and then passed on a retry (`status_reason = 'flaky'`). + * + * D4 — the denominator is EXECUTIONS, the same grain the runs list and uptime + * use. One record is one execution (one location × browser × device), so + * `flakyExecutions / totalRuns` compares like with like. Labelling it "runs" + * would invite dividing by the number of scheduled runs, which is smaller by + * the fan-out factor and inflates the rate several-fold. + */ + flakyExecutions: number; + /** + * Executions that are `warning` for a reason other than flakiness — a + * certificate inside its warning window, an SFTP probe that failed on an + * otherwise healthy host. Degradation is a property of the target, not of + * the test, so it must not read as flakiness. + */ + degradedExecutions: number; lastRunStatus: RunStatus | null; lastRunAt: number | null; } @@ -103,6 +147,20 @@ export interface SyntheticRun { scheduledTs: number; status: RunStatus; durationMs: number; + /** Probe start-up, INSIDE `durationMs` — subtract, never add (C4). */ + initMs: number; + /** When the probe actually began, in ms. 0 on records written before C5. */ + startedTs: number; + /** + * scheduled -> started. `null` when `startedTs` is absent, so "no delay" and + * "the record predates the field" stay distinguishable — rendering an unknown + * as 0 ms would claim the scheduler was perfect on every historical row. + */ + queueDelayMs: number | null; + /** Set only on `warning`; see STATUS_REASON. */ + statusReason: string; + /** Set only on `error`; see ERROR_SOURCE. */ + errorSource: string; location: string; device: string; browserEngine: string; @@ -122,6 +180,14 @@ export interface SyntheticRunDetail extends SyntheticRun { recordedSteps: RecordedStep[]; lastAttemptSteps: StepExecution[]; retryHistory: RetryAttempt[]; + /** Spec P5.4 — present exactly when the final attempt failed. */ + failureDetail: FailureDetail | null; + /** Browser-side evidence summary, per step that had something to report. */ + evidenceByStep: StepEvidence[]; + /** The bundle's object-storage key, when one was uploaded. */ + evidenceKey: string | null; + /** True when the capture cap bound — X-8.2, reduced fidelity is reported. */ + evidenceTruncated: boolean; network: NetworkStats | null; webVitals: WebVitals | null; traceKey: string | null; @@ -209,6 +275,288 @@ export interface RetryAttempt { durationMs: number; failedStep: string | null; steps: StepExecution[]; + /** Why THIS attempt failed. Null on an attempt that passed. */ + failureDetail: FailureDetail | null; + /** This attempt's own artifacts, uploaded under an attempt-scoped key. */ + screenshotKeys: Map; + traceKey: string | null; + evidenceKey: string | null; +} + +/** + * One row of the attempts strip: the superseded attempts and the deciding one + * in a single uniform list. + * + * The record stores them in two shapes — `retry_history[]` carries a compact + * per-attempt timeline, `last_attempt_steps` carries the deciding attempt's + * full detail. A view that has to branch on which shape it is holding ends up + * rendering two different panels for the same concept. + */ +export interface AttemptView extends RetryAttempt { + /** + * The attempt whose outcome became the run's verdict — always the last one. + * Note this is about the ATTEMPT, not the run: on a flaky run the deciding + * attempt passed while the run is reported `warning`. + */ + decided: boolean; + /** True when only the compact timeline exists, so the panel shows the + * reduced-detail state with an explanation instead of a blank. */ + compact: boolean; +} + +/** + * The seven items of spec P5.4 — everything needed to understand a failed run + * without reproducing it. + * + * Written by the probe on every failed run since Phase 5 and read by nothing. + * Numbered here the way the spec numbers them, so a missing one is visible + * rather than merely absent. + */ +export interface FailureDetail { + /** 1 — which step. */ + stepId: string; + stepName: string; + stepIndex: number; + /** 2 — the exact wait or assertion that timed out. */ + error: string; + /** 3 — candidates tried, in order, with outcomes. */ + candidatesTried: LocatorAttempt[]; + /** 4 — which settle signals fired and which went stale. */ + settleSignals: SettleSignal[]; + /** 5 — observed today vs. observed while recording. */ + settleMs: number | null; + observedDurationMs: number | null; + /** 6 and 7 — object-storage keys, filled after upload. */ + screenshotKey: string | null; + traceKey: string | null; +} + +/** One locator candidate the probe tried, and what happened to it. */ +export interface LocatorAttempt { + kind: string; + value: string; + outcome: "matched" | "not_found" | "used_as_primary" | "not_tried"; +} + +/** One recorded settle signal, and whether it arrived this run. */ +export interface SettleSignal { + kind: "navigation" | "response"; + signal: string; + status: "fired" | "stale"; + required: boolean; + waitedMs: number; +} + +/** + * Per-step counts from the evidence bundle, inlined on the record. + * + * The bundle itself lives in object storage; this fixed shape is what makes + * "every failure of step 9 last week that coincided with a 5xx" an ordinary + * query — no join, no new stream, nothing unbounded in the record. + */ +export interface StepEvidence { + stepId: string; + consoleErrors: number; + pageErrors: number; + requestsFailed: number; + responsesNon2xx: number; + worstResponses: Array<{ method: string; url: string; status: number; count: number }>; + firstConsoleErrors: string[]; +} + +// ── Evidence bundle (evidence.ndjson) ────────────────────────────────────── +// +// The bundle is the full browser-side log for one ATTEMPT: console messages, +// page errors, and network requests/responses, each attributed to the step whose +// window it fell in. `evidence_by_step` on the record is only an anomaly INDEX — +// `summarise()` emits a row solely for a step that had a console error, a page +// error, a failed request or a non-2xx response. A run whose network was healthy +// therefore carries an empty index while the bundle holds every event, which is +// why the panel reads the bundle rather than the index. + +export type EvidenceKind = + | "console" + | "pageerror" + | "response" + | "requestfailed" + | "dialog" + | "crash" + | "truncation"; + +export interface EvidenceEvent { + /** When the event was OBSERVED. */ + ts: number; + /** Which step's window it fell in. Absent if bucketing could not attribute it. */ + stepId: string | null; + kind: EvidenceKind; + // console + level: string | null; + text: string | null; + // pageerror / crash / dialog + message: string | null; + stack: string | null; + // network + method: string | null; + url: string | null; + status: number | null; + resourceType: string | null; + /** + * When the request STARTED, which is what the event is bucketed on. + * + * Kept alongside `ts` because work begun in step 9 routinely completes during + * step 10; collapsing them would hide the ambiguity rather than show it. + */ + initiatedTs: number | null; + durationMs: number | null; + firstParty: boolean; + /** Resolved step name, filled by `foldEvidenceBundle`. Null when unattributed. */ + stepName?: string | null; +} + +function evidenceNum(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +/** + * Parse one NDJSON bundle. + * + * NDJSON, not JSON — one object per line. `JSON.parse` on the whole payload + * fails, which is why the download button must not be labelled "JSON" either. + * + * Parsed per line and guarded per line: a single malformed line drops that line + * rather than the panel. A truncated upload ends mid-line by construction, so + * this is the expected case at the cap, not a corruption. + */ +export function parseEvidenceNdjson(text: string): EvidenceEvent[] { + const out: EvidenceEvent[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + let e: any; + try { + e = JSON.parse(trimmed); + } catch { + continue; + } + if (!e || typeof e !== "object") continue; + out.push({ + ts: evidenceNum(e.ts) ?? 0, + stepId: e.step_id ? str(e.step_id) : null, + kind: (e.kind ?? "response") as EvidenceKind, + level: e.level ? str(e.level) : null, + text: e.text ? str(e.text) : null, + message: e.message ? str(e.message) : null, + stack: e.stack ? str(e.stack) : null, + method: e.method ? str(e.method) : null, + url: e.url ? str(e.url) : null, + status: evidenceNum(e.status), + resourceType: e.resource_type ? str(e.resource_type) : null, + initiatedTs: evidenceNum(e.initiated_ts), + durationMs: evidenceNum(e.duration_ms), + firstParty: e.first_party !== false, + }); + } + return out; +} + +/** Is this event something an engineer would call a problem? */ +export function isEvidenceAnomaly(e: EvidenceEvent): boolean { + if (e.kind === "console") return e.level === "error"; + if (e.kind === "pageerror" || e.kind === "crash" || e.kind === "requestfailed") return true; + return e.kind === "response" && (e.status ?? 0) >= 400; +} + +/** + * One kind of event, in the order the events were initiated. + * + * Grouped by KIND, not by step. Step grouping reads well in a wireframe and + * degenerates on real data: a live 158-event bundle held only two distinct + * `step_id`s, so it produced one group of 136 and one of 22 — the grouping told + * the reader nothing the flat list didn't. Kind also matches what devtools + * trains people to expect (Console / Network). + * + * Attribution is not lost, it moves: every row carries its resolved step name. + */ +export interface EvidenceGroup { + kind: "pageErrors" | "requestsFailed" | "console" | "network"; + events: EvidenceEvent[]; + /** True when any event in the group is an anomaly — drives the header accent. */ + hasAnomaly: boolean; +} + +export interface EvidenceBundle { + events: EvidenceEvent[]; + groups: EvidenceGroup[]; + counts: { + all: number; + consoleErrors: number; + pageErrors: number; + requestsFailed: number; + nonNon2xx: number; + }; + /** A `truncation` event in the stream, or `evidence_truncated` on the record. */ + truncated: boolean; +} + +/** Severity order: what to read first, not what there is most of. */ +const EVIDENCE_GROUP_ORDER: EvidenceGroup["kind"][] = [ + "pageErrors", + "requestsFailed", + "console", + "network", +]; + +function groupKindOf(e: EvidenceEvent): EvidenceGroup["kind"] { + if (e.kind === "pageerror" || e.kind === "crash") return "pageErrors"; + if (e.kind === "requestfailed") return "requestsFailed"; + if (e.kind === "console" || e.kind === "dialog") return "console"; + return "network"; +} + +/** + * Fold a bundle into the panel's view model. + * + * `stepDefs` resolves each event's `step_id` to a name for display on the row. + * An unresolved id renders as the id — never blank, and never guessed from the + * check's current config, which would relabel history after an edit. + */ +export function foldEvidenceBundle( + events: EvidenceEvent[], + stepDefs: Map | Map, + recordTruncated = false, +): EvidenceBundle { + const named = events.map((e) => ({ + ...e, + stepName: e.stepId ? stepDefs.get(e.stepId)?.name || e.stepId : null, + })); + + const byKind = new Map(); + for (const e of named) { + const k = groupKindOf(e); + const list = byKind.get(k); + if (list) list.push(e); + else byKind.set(k, [e]); + } + + const groups: EvidenceGroup[] = EVIDENCE_GROUP_ORDER.filter((k) => byKind.has(k)).map((kind) => { + const list = [...byKind.get(kind)!].sort( + (x, y) => (x.initiatedTs ?? x.ts) - (y.initiatedTs ?? y.ts), + ); + return { kind, events: list, hasAnomaly: list.some(isEvidenceAnomaly) }; + }); + + return { + events: named, + groups, + counts: { + all: named.length, + consoleErrors: named.filter((e) => e.kind === "console" && e.level === "error").length, + pageErrors: named.filter((e) => e.kind === "pageerror" || e.kind === "crash").length, + requestsFailed: named.filter((e) => e.kind === "requestfailed").length, + nonNon2xx: named.filter((e) => e.kind === "response" && (e.status ?? 0) >= 400).length, + }, + truncated: recordTruncated || named.some((e) => e.kind === "truncation"), + }; } export interface NetworkStats { @@ -330,6 +678,25 @@ export interface StepFailureInstance { executionId: string; } +/** + * What the step tally actually covered (P2a). + * + * The query takes the newest N rows, so on a busy check the panel describes a + * window far shorter than the one the time picker shows — a 1-minute check + * across 2 locations × 4 browser/device combos produces 11 520 executions a + * day, so a 5000-row cap is about ten hours of a "last 7 days" selection. The + * numbers were right; the label was wrong, and silently so. + */ +export interface StepStatsCoverage { + /** Executions actually tallied. */ + executions: number; + /** Oldest and newest execution in the tally, in ms. 0 when empty. */ + fromMs: number; + toMs: number; + /** The row cap bound, so the window is narrower than the one requested. */ + truncated: boolean; +} + export interface StepStatsResult { stepFailures: StepFailure[]; stepDurations: StepDuration[]; @@ -337,6 +704,7 @@ export interface StepStatsResult { flakySteps: FlakyStep[]; trendBuckets: TrendBucket[]; failureInstances: StepFailureInstance[]; + coverage: StepStatsCoverage; } // ── Internal helpers ─────────────────────────────────────────────────────── @@ -458,17 +826,30 @@ export function buildKpiSql( * (e.g. on instances where the probe doesn't write this field), the * retried_runs clause is omitted to avoid a schema-mismatch error. */ hasAttemptsField = false, + /** Whether the stream schema includes `status_reason`. Same gate: the search + * API rejects a query naming a field the schema doesn't have. */ + hasStatusReasonField = false, ): string { const id = escapeSqlLiteral(monitorId); const retriedClause = hasAttemptsField ? `\n COUNT(*) FILTER (WHERE attempts > 1) as retried_runs,` : ""; + // `warning` is produced by two unrelated layers: the retry loop (flaky) and a + // checker reporting a reachable-but-degrading target (cert_expiring, + // sftp_degraded). Counting them together reported a TLS check with a + // soon-expiring certificate as ~100% flaky forever. `status_reason` splits + // them, and these are two more FILTER clauses over the scan the query + // already does — no extra pass, no extra bytes. + const reasonClauses = hasStatusReasonField + ? `\n COUNT(*) FILTER (WHERE ${F.status} = '${STATUS_VALUES.warning}' AND status_reason = '${STATUS_REASON.flaky}') as flaky_runs,` + + `\n COUNT(*) FILTER (WHERE ${F.status} = '${STATUS_VALUES.warning}' AND status_reason != '' AND status_reason != '${STATUS_REASON.flaky}') as degraded_runs,` + : ""; return `SELECT COUNT(*) as total_runs, COUNT(*) FILTER (WHERE ${F.status} = '${STATUS_VALUES.passed}') as passed_runs, COUNT(*) FILTER (WHERE ${F.status} = '${STATUS_VALUES.warning}') as warning_runs, COUNT(*) FILTER (WHERE ${F.status} = '${STATUS_VALUES.failed}') as failed_runs, - COUNT(*) FILTER (WHERE ${F.status} = '${STATUS_VALUES.error}') as error_runs,${retriedClause} + COUNT(*) FILTER (WHERE ${F.status} = '${STATUS_VALUES.error}') as error_runs,${retriedClause}${reasonClauses} COALESCE(approx_percentile_cont(${F.duration}, 0.95), 0) as p95_duration FROM ${TABLE} WHERE ${F.monitorId} = '${id}'`; @@ -507,11 +888,38 @@ ORDER BY ts`; * on protocol-only deployments, `error` is missing until a run has failed, etc. * The search API rejects any query naming an absent field, so each column is * selected as a literal instead when missing — the row shape stays constant. */ +/** + * PROJECTION RULE — do not break this. + * + * The list and KPI queries name their columns explicitly and MUST NOT select + * `retry_history`, `recorded_steps`, `last_attempt_steps`, `assertions` or + * `evidence_by_step`. Those are JSON blob columns; on a 5000-row aggregation + * one of them was ~20 MB of duplicated payload. + * + * Blob columns belong to the single-row detail query only + * (`buildRunDetailSql` / `buildProtocolRunDetailSql` use `SELECT *` + * deliberately — one row). + * + * `buildRunsWithStepsSql` is the one intentional exception: it needs + * `last_attempt_steps` and `retry_history` to tally per-step stats. It still + * must not select `recorded_steps` — see `buildStepDefsSql`. + */ const RUNS_COLUMNS: { field: string; alias: string; fallback: string }[] = [ { field: F.timestamp, alias: "ts", fallback: "0" }, { field: "scheduled_ts", alias: "scheduled_ts", fallback: "0" }, { field: F.status, alias: "status", fallback: "''" }, { field: F.duration, alias: "duration", fallback: "0" }, + // C4 — probe start-up, already inside `duration`. Observed at 113 131 ms on a + // cold Lambda against a 243 ms check: unsubtracted, Lambda locations look + // permanently slower than private agents at every percentile. + { field: "init_ms", alias: "init_ms", fallback: "0" }, + // C5 — with only scheduled_ts and _timestamp, queue delay and run duration + // are one number. started_ts splits them: scheduled -> started is the delay + // the scheduler owns, started -> completed is the check itself. + { field: "started_ts", alias: "started_ts", fallback: "0" }, + // Warning-only and error-only discriminators; '' means "not set / older row". + { field: "status_reason", alias: "status_reason", fallback: "''" }, + { field: "error_source", alias: "error_source", fallback: "''" }, { field: F.location, alias: "location", fallback: "''" }, { field: F.device, alias: "device", fallback: "''" }, { field: F.engine, alias: "engine", fallback: "''" }, @@ -554,7 +962,227 @@ export function buildRunsWithStepsSql( ): string { const id = escapeSqlLiteral(monitorId); const retryHistoryCol = hasRetryHistoryField ? ", retry_history" : ""; - return `SELECT ${F.timestamp} as ts, scheduled_ts, ${F.status} as status, ${F.duration} as duration, ${F.location} as location, ${F.device} as device, ${F.engine} as engine, trigger_type, ${F.error} as error, job_id, run_id, execution_id, attempts, last_attempt_steps, recorded_steps${retryHistoryCol} + // `recorded_steps` is deliberately NOT selected here. It is ~4 KB per row and + // near-identical across rows of one config version, so selecting it on a + // 5000-row aggregation dragged ~20 MB of duplicate step definitions across + // the wire — roughly 60% of the panel's payload. The step_id → {name, + // selector} lookup comes from `buildStepDefsSql` over a bounded row subset + // instead. + return `SELECT ${F.timestamp} as ts, scheduled_ts, ${F.status} as status, ${F.duration} as duration, ${F.location} as location, ${F.device} as device, ${F.engine} as engine, trigger_type, ${F.error} as error, job_id, run_id, execution_id, attempts, last_attempt_steps${retryHistoryCol} +FROM ${TABLE} +WHERE ${F.monitorId} = '${id}' +ORDER BY ${F.timestamp} DESC +LIMIT ${limit}`; +} + +/** + * Step definitions for the name/selector lookup, from a bounded row subset. + * + * Deliberately NOT sourced from the check's current `config.steps`: a user can + * edit steps at any time, and each result stores the definitions **as they were + * at run time**. Reading the live config would silently relabel history. + * + * Step ids are stable across edits, so unioning the newest N rows resolves every + * definition version present in practice. A `step_id` that appears in the tally + * but not in this lookup renders as its id. + */ +/** + * Which steps this check retries on, and whether it always retries the same way. + * + * This replaces reading `retry_history` on every row of the step tally. That + * column is ~1 KB per attempt and was fetched across 5000 rows purely to + * recover the step ids inside it — tens of megabytes to answer a question that + * is three scalars per row. + * + * The probe now denormalises those scalars into columns (`retry_step_ids`, + * `retry_error_classes`, `retry_consistent`), because OpenObserve stores arrays + * as opaque JSON strings — no `unnest`, no `arr_index` — so the array cannot be + * aggregated in SQL at all. Only rows that actually retried are scanned. + */ +export function buildRetryAttributionSql( + monitorId: string, + limit = 5000, + /** Whether `status_reason` is in the stream schema. + * + * It is written ONLY on `warning`, so on a deployment where nothing has ever + * recovered-on-retry or reported a degraded target, the field does not exist + * — and the search API rejects any query naming a field the schema lacks. + * Naming it unconditionally took the whole Steps tab down with + * "unknown field 'status_reason'". */ + hasStatusReason = true, +): string { + const id = escapeSqlLiteral(monitorId); + // Absent `status_reason` is itself informative: no warning record with a + // reason has ever been written, so nothing in this window recovered. + const reasonCol = hasStatusReason ? "status_reason" : "'' as status_reason"; + return `SELECT ${F.executionId} as execution_id, ${F.status} as status, ${reasonCol}, attempts, retry_step_ids, retry_error_classes, retry_consistent +FROM ${TABLE} +WHERE ${F.monitorId} = '${id}' AND attempts > 1 +ORDER BY ${F.timestamp} DESC +LIMIT ${limit}`; +} + +/** One step's retry profile, from `buildRetryAttributionSql`. */ +export interface StepRetryProfile { + /** Executions that retried and involved this step. */ + retriedExecutions: number; + /** Of those, the ones that recovered — failed, retried, passed. */ + flakyExecutions: number; +} + +export interface RetryAttributionSummary { + /** + * execution_id → the steps that failed in some attempt of that execution. + * + * The per-execution join the step tally needs. Without it the tally has to + * re-parse `retry_history` on every row, which is the fetch C7 exists to + * delete. + */ + byExecution: Map>; + byStep: Map; + byErrorClass: Map; + /** Executions that retried and failed the same way every time. */ + consistentFailures: number; + /** Executions that retried at all — the denominator for the two above. */ + retriedExecutions: number; +} + +/** + * Split a delimited attribution column back into its members. + * + * The probe wraps the set in leading and trailing commas so that `LIKE '%,s2,%'` + * is an exact membership test rather than a substring match that also finds + * `s20`. Splitting therefore has to drop the empty leading and trailing + * segments that wrapping produces. + */ +export function splitDelimited(raw: unknown): string[] { + const v = str(raw); + if (!v) return []; + return v.split(",").filter(Boolean); +} + +export function foldRetryAttribution(hits: Record[]): RetryAttributionSummary { + const byExecution = new Map>(); + const byStep = new Map(); + const byErrorClass = new Map(); + let consistentFailures = 0; + let retriedExecutions = 0; + + for (const hit of hits) { + retriedExecutions++; + const executionId = str(hit.execution_id); + const stepIds = splitDelimited(hit.retry_step_ids); + if (executionId) byExecution.set(executionId, new Set(stepIds)); + // D2 — attribution is written on ANY retried execution, not only flaky + // ones, so "recovered" has to be read from the verdict rather than assumed + // from the row's presence. + const recovered = + str(hit.status) === STATUS_VALUES.warning && str(hit.status_reason) === STATUS_REASON.flaky; + + for (const stepId of stepIds) { + const acc = byStep.get(stepId) ?? { retriedExecutions: 0, flakyExecutions: 0 }; + acc.retriedExecutions++; + if (recovered) acc.flakyExecutions++; + byStep.set(stepId, acc); + } + for (const cls of splitDelimited(hit.retry_error_classes)) { + byErrorClass.set(cls, (byErrorClass.get(cls) ?? 0) + 1); + } + // Only an explicit `true` counts. `retry_consistent` is deliberately absent + // when fewer than two attempts failed (D1) — treating absent as `false` + // would report every recovered run as non-deterministic. + if (hit.retry_consistent === true) consistentFailures++; + } + + return { byExecution, byStep, byErrorClass, consistentFailures, retriedExecutions }; +} + +/** One (location, device, engine) slice of a check, and how settled it is. */ +export interface PartitionStability { + key: string; + location: string; + device: string; + engine: string; + executions: number; + /** Pass ↔ not-pass changes across the window, in time order. */ + transitions: number; + /** Two or more transitions: the outcome is oscillating, not merely down. */ + unstable: boolean; +} + +/** + * Which slices of a check are oscillating. + * + * Partitioned by (location, device, engine) because those are the axes a run + * fans out along. Aggregated across them, a check that is solidly broken in one + * region and solidly healthy in five reads as an 83% pass rate — indistinguish- + * able from one that is intermittently broken everywhere, which is a completely + * different problem with a completely different fix. + * + * "Unstable" means the outcome CHANGED repeatedly, not that it is bad. A slice + * that failed once and stayed failed is down — one transition — and belongs on + * the failure tile, not here. Two or more transitions is the smallest signal + * that cannot be explained by a single state change. + * + * Computed client-side over rows `buildRunsSql` already returns: no extra query. + */ +export function computePartitionStability(runs: SyntheticRun[]): PartitionStability[] { + const groups = new Map(); + for (const run of runs) { + const key = `${run.location}|${run.device}|${run.browserEngine}`; + const bucket = groups.get(key); + if (bucket) bucket.push(run); + else groups.set(key, [run]); + } + + const out: PartitionStability[] = []; + for (const [key, group] of groups) { + // Oldest-first: a transition is only meaningful in time order. + const ordered = [...group].sort((a, b) => a.timestamp - b.timestamp); + let transitions = 0; + let previous: boolean | null = null; + for (const run of ordered) { + // `error` means we could not look, so it is neither a pass nor a failure + // and must not register as a transition in either direction. + if (run.status === STATUS_VALUES.error) continue; + const healthy = run.status === STATUS_VALUES.passed || run.status === STATUS_VALUES.warning; + if (previous !== null && healthy !== previous) transitions++; + previous = healthy; + } + const [location = "", device = "", engine = ""] = key.split("|"); + out.push({ + key, + location, + device, + engine, + executions: group.length, + transitions, + unstable: transitions >= 2, + }); + } + return out.sort((a, b) => b.transitions - a.transitions); +} + +/** Fold `recorded_steps` blobs into one step_id → definition lookup. + * Rows arrive newest-first, so the first definition seen for an id wins and a + * rename shows its current name while older rows still resolve. */ +export function foldStepDefs( + hits: Record[], +): Map { + const defs = new Map(); + for (const hit of hits) { + for (const rs of parseJsonArray(hit.recorded_steps) as any[]) { + const id = str(rs.id); + if (!id || defs.has(id)) continue; + defs.set(id, { name: str(rs.name) || id, selector: effectiveSelector(rs) }); + } + } + return defs; +} + +export function buildStepDefsSql(monitorId: string, limit = 100): string { + const id = escapeSqlLiteral(monitorId); + return `SELECT recorded_steps FROM ${TABLE} WHERE ${F.monitorId} = '${id}' ORDER BY ${F.timestamp} DESC @@ -574,9 +1202,70 @@ const RUN_DETAIL_COLUMNS: { field: string; alias: string; fallback: string }[] = { field: F.engine, alias: "engine", fallback: "''" }, { field: F.error, alias: "error", fallback: "''" }, { field: F.monitorName, alias: "synthetics_name", fallback: "''" }, + { field: "scheduled_ts", alias: "scheduled_ts", fallback: "0" }, + // C4 — probe start-up, already inside `duration`. Observed at 113 131 ms on a + // cold Lambda against a 243 ms check: unsubtracted, Lambda locations look + // permanently slower than private agents at every percentile. + { field: "init_ms", alias: "init_ms", fallback: "0" }, + // C5 — with only scheduled_ts and _timestamp, queue delay and run duration + // are one number. started_ts splits them: scheduled -> started is the delay + // the scheduler owns, started -> completed is the check itself. + { field: "started_ts", alias: "started_ts", fallback: "0" }, + // Warning-only and error-only discriminators; '' means "not set / older row". + { field: "status_reason", alias: "status_reason", fallback: "''" }, + { field: "error_source", alias: "error_source", fallback: "''" }, { field: "job_id", alias: "job_id", fallback: "''" }, { field: F.executionId, alias: "execution_id", fallback: "''" }, { field: "trace_key", alias: "trace_key", fallback: "''" }, + { field: "run_id", alias: "run_id", fallback: "''" }, + // C2 — the attempts strip and the retry chip read these. Selecting them here + // is correct and is NOT a violation of the projection rule: that rule bans + // blob columns from the LIST and KPI queries, which scan thousands of rows. + // This query fetches ONE row, which is exactly why the attempts view costs no + // extra request. Without them `retry_history` was always empty, so the strip + // never rendered and `attempts` was always 0. + { field: "attempts", alias: "attempts", fallback: "0" }, + { field: "retry_history", alias: "retry_history", fallback: "''" }, + { field: "total_attempt_ms", alias: "total_attempt_ms", fallback: "0" }, + // NOT `failure_detail` — OpenObserve flattens nested objects into columns, so + // the record carries `failure_detail_step_id`, `failure_detail_error`, … and + // no `failure_detail` at all. Naming the object would be rejected exactly as + // `status_reason` was in the step-stats query. Reassembled by + // `flattenedFailureDetail` below. + { field: "failure_detail_step_id", alias: "failure_detail_step_id", fallback: "''" }, + { field: "failure_detail_step_name", alias: "failure_detail_step_name", fallback: "''" }, + { field: "failure_detail_step_index", alias: "failure_detail_step_index", fallback: "0" }, + { field: "failure_detail_error", alias: "failure_detail_error", fallback: "''" }, + { + field: "failure_detail_candidates_tried", + alias: "failure_detail_candidates_tried", + fallback: "''", + }, + { + field: "failure_detail_settle_signals", + alias: "failure_detail_settle_signals", + fallback: "''", + }, + { field: "failure_detail_settle_ms", alias: "failure_detail_settle_ms", fallback: "0" }, + { + field: "failure_detail_observed_duration_ms", + alias: "failure_detail_observed_duration_ms", + fallback: "0", + }, + { + field: "failure_detail_screenshot_key", + alias: "failure_detail_screenshot_key", + fallback: "''", + }, + // Evidence: the key opens the bundle, the summary is the inline anomaly index, + // and `evidence_truncated` is what stops a capped capture reading as a quiet run. + { field: "evidence_key", alias: "evidence_key", fallback: "''" }, + { field: "evidence_by_step", alias: "evidence_by_step", fallback: "''" }, + { field: "evidence_truncated", alias: "evidence_truncated", fallback: "false" }, + // Drives the determinism line. NULL below two failing attempts (D1), so the + // UI must distinguish absent from false. + { field: "retry_consistent", alias: "retry_consistent", fallback: "NULL" }, + { field: "retry_step_ids", alias: "retry_step_ids", fallback: "''" }, { field: "last_attempt_steps", alias: "last_attempt_steps", fallback: "''" }, { field: "recorded_steps", alias: "recorded_steps", fallback: "''" }, ]; @@ -650,9 +1339,16 @@ export function mapKpi( const failedRuns = num(rawKpiRow?.failed_runs); const errorRuns = num(rawKpiRow?.error_runs); const retriedRuns = num(rawKpiRow?.retried_runs); + const flakyExecutions = num(rawKpiRow?.flaky_runs); + const degradedExecutions = num(rawKpiRow?.degraded_runs); const lastRunTsRaw = rawLastRun ? num(rawLastRun.ts) : 0; return { - uptimePct: totalRuns > 0 ? ((passedRuns + warningRuns) / totalRuns) * 100 : 0, + // P6a — `error` is excluded from BOTH sides. It means "we could not look", + // not "the service was down"; leaving it in the denominator understates + // uptime by exactly our own dispatch-failure rate. `errorRuns` is reported + // separately so the omission is visible rather than silent. + uptimePct: + totalRuns - errorRuns > 0 ? ((passedRuns + warningRuns) / (totalRuns - errorRuns)) * 100 : 0, p95Ms: num(rawKpiRow?.p95_duration), passedRuns, warningRuns, @@ -660,17 +1356,29 @@ export function mapKpi( errorRuns, totalRuns, retriedRuns, + flakyExecutions, + degradedExecutions, lastRunStatus: rawLastRun ? toRunStatus(rawLastRun.status) : null, lastRunAt: lastRunTsRaw > 0 ? lastRunTsRaw / 1000 : null, }; } export function mapRun(rawHit: Record): SyntheticRun { + const scheduledTs = num(rawHit.scheduled_ts) / 1000; + const startedTs = num(rawHit.started_ts) / 1000; return { timestamp: num(rawHit.ts) / 1000, - scheduledTs: num(rawHit.scheduled_ts) / 1000, + scheduledTs, status: toRunStatus(rawHit.status), durationMs: num(rawHit.duration), + initMs: num(rawHit.init_ms), + startedTs, + // Both stamps must be present for the difference to mean anything, and a + // negative delay is a clock artefact, not a scheduler that ran early. + queueDelayMs: + startedTs > 0 && scheduledTs > 0 ? Math.max(0, Math.round(startedTs - scheduledTs)) : null, + statusReason: str(rawHit.status_reason), + errorSource: str(rawHit.error_source), location: str(rawHit.location), device: str(rawHit.device), browserEngine: str(rawHit.engine), @@ -682,12 +1390,207 @@ export function mapRun(rawHit: Record): SyntheticRun { }; } +/** + * `status` is READ, not derived. + * + * It used to be hard-coded to `"failed"` on the reasoning that an entry existed + * only because that attempt failed. The probe now records every attempt, the + * deciding one included, so hard-coding would report a passing final attempt as + * a failure — and on a flaky run that is the only attempt that passed. + * + * `durationMs` prefers the probe's own `response_time_ms`. Summing step + * durations misses everything between steps (browser launch, settle waits, the + * navigation a step triggers), which on a real journey is most of the time. + */ +function mapRetryHistory(raw: unknown): RetryAttempt[] { + // `parseJsonArray`, not `Array.isArray`: the search API hands blob columns + // back as JSON STRINGS. Guarding on Array.isArray meant this returned [] for + // every real row, so the attempts strip could never render no matter what the + // query selected — `aggregateStepStats` already parsed the same column + // correctly, which is what hid the asymmetry. + return parseJsonArray(raw).map((a: any, i: number) => { + // Normalised to the same vocabulary `lastAttemptSteps` uses. The probe + // writes `passed`/`failed`/`skipped` on the compact timeline while + // `StepExecution` declares `ok`/`fail`/`skipped`, and every consumer tests + // for `fail` — so passing these through raw rendered a superseded attempt's + // FAILING step as a pass: a green tick on the step that actually broke. + // + // `skipped` survives: an `optional` step exists precisely because it may not + // be there, and collapsing it to `fail` reports a correctly-skipped step as + // a broken one. + const steps: StepExecution[] = parseJsonArray(a?.steps).map((st: any) => ({ + ...st, + status: + st?.status === "ok" || st?.status === "passed" + ? ("ok" as const) + : st?.status === "skipped" + ? ("skipped" as const) + : ("fail" as const), + })); + const summed = steps.reduce((sum, st) => sum + (st.duration_ms ?? 0), 0); + const refs: Array<{ step_id?: unknown; key?: unknown }> = Array.isArray( + a?.artifacts?.screenshot_refs, + ) + ? a.artifacts.screenshot_refs + : []; + return { + attempt: typeof a?.attempt === "number" ? a.attempt : i, + status: a?.status === STATUS_VALUES.passed ? STATUS_VALUES.passed : STATUS_VALUES.failed, + durationMs: typeof a?.response_time_ms === "number" ? a.response_time_ms : summed, + failedStep: + a?.failure_detail?.step_id ?? + steps.find((st: any) => st.status === "failed" || st.status === "fail")?.step_id ?? + null, + steps, + failureDetail: mapFailureDetail(a?.failure_detail), + screenshotKeys: new Map(refs.map((r) => [str(r.step_id), str(r.key)])), + traceKey: a?.artifacts?.trace_ref ? str(a.artifacts.trace_ref) : null, + evidenceKey: a?.artifacts?.evidence_ref ? str(a.artifacts.evidence_ref) : null, + }; + }); +} + +/** + * Fold a run detail into the uniform attempts strip. + * + * Costs NO query: `retry_history` is already on the run-detail row, so + * switching between attempts is local state, not a fetch. + * + * The last entry is the deciding attempt, and it is the one the record's + * top-level fields describe — so its compact timeline is replaced with + * `lastAttemptSteps` and its artifacts with the record's own. Earlier attempts + * keep the compact form and are marked as such, which is what lets the panel + * explain the reduced detail rather than render an empty forensics section. + */ +export function buildAttemptViews(detail: SyntheticRunDetail): AttemptView[] { + const history = detail.retryHistory; + // A run with no history at all is still one attempt — the one that ran. + if (history.length === 0) { + return [ + { + attempt: 0, + status: + detail.status === STATUS_VALUES.passed ? STATUS_VALUES.passed : STATUS_VALUES.failed, + durationMs: detail.durationMs, + failedStep: detail.failedStep, + steps: detail.lastAttemptSteps, + failureDetail: detail.failureDetail, + screenshotKeys: new Map(), + traceKey: detail.traceKey, + evidenceKey: detail.evidenceKey, + decided: true, + compact: false, + }, + ]; + } + + return history.map((a, i) => { + const decided = i === history.length - 1; + if (!decided) return { ...a, decided, compact: true }; + return { + ...a, + steps: detail.lastAttemptSteps.length ? detail.lastAttemptSteps : a.steps, + failureDetail: detail.failureDetail ?? a.failureDetail, + traceKey: detail.traceKey ?? a.traceKey, + evidenceKey: detail.evidenceKey ?? a.evidenceKey, + decided, + compact: false, + }; + }); +} + +function mapEvidence(raw: unknown): StepEvidence[] { + // Same string-vs-array trap as mapRetryHistory. + return parseJsonArray(raw).map((e: any) => ({ + stepId: str(e?.step_id), + consoleErrors: e?.console_errors ?? 0, + pageErrors: e?.page_errors ?? 0, + requestsFailed: e?.requests_failed ?? 0, + responsesNon2xx: e?.responses_non_2xx ?? 0, + worstResponses: Array.isArray(e?.worst_responses) ? e.worst_responses : [], + firstConsoleErrors: Array.isArray(e?.first_console_errors) ? e.first_console_errors : [], + })); +} + +/** + * `traceKey` is passed in rather than read off the failure detail: the trace + * covers the whole execution, not the failing step, so it lives on the record. + * The probe used to duplicate it inside `failure_detail` as well — records + * written then still carry it, so that value is preferred when present and the + * record-level key is the fallback. + */ +function mapFailureDetail(raw: unknown, recordTraceKey?: unknown): FailureDetail | null { + if (!raw || typeof raw !== "object") return null; + const d = raw as any; + return { + stepId: str(d.step_id), + stepName: str(d.step_name), + stepIndex: typeof d.step_index === "number" ? d.step_index : 0, + error: str(d.error), + candidatesTried: Array.isArray(d.candidates_tried) ? d.candidates_tried : [], + settleSignals: Array.isArray(d.settle_signals) + ? d.settle_signals.map((sig: any) => ({ + kind: sig?.kind, + signal: str(sig?.signal), + status: sig?.status, + required: !!sig?.required, + waitedMs: typeof sig?.waited_ms === "number" ? sig.waited_ms : 0, + })) + : [], + settleMs: typeof d.settle_ms === "number" ? d.settle_ms : null, + observedDurationMs: typeof d.observed_duration_ms === "number" ? d.observed_duration_ms : null, + screenshotKey: d.screenshot_key ? str(d.screenshot_key) : null, + traceKey: d.trace_key ? str(d.trace_key) : recordTraceKey ? str(recordTraceKey) : null, + }; +} + +/** + * Rebuild the nested `failure_detail` object from its flattened columns. + * + * The probe writes a nested object; the stream stores it as + * `failure_detail_` columns. `SELECT *` (the protocol path) returns the + * flattened form, and so does the browser detail query now that it names them. + * Either way the mapper wants one object. + * + * Returns null when there is no failing step, so a passing run does not get an + * empty forensics panel. + */ +function flattenedFailureDetail(rawHit: Record): unknown { + if (rawHit.failure_detail) return rawHit.failure_detail; // already nested + const stepId = str(rawHit.failure_detail_step_id); + if (!stepId) return null; + return { + step_id: stepId, + step_name: str(rawHit.failure_detail_step_name), + step_index: num(rawHit.failure_detail_step_index), + error: str(rawHit.failure_detail_error), + candidates_tried: parseJsonArray(rawHit.failure_detail_candidates_tried), + settle_signals: parseJsonArray(rawHit.failure_detail_settle_signals), + settle_ms: + rawHit.failure_detail_settle_ms == null ? undefined : num(rawHit.failure_detail_settle_ms), + observed_duration_ms: + rawHit.failure_detail_observed_duration_ms == null + ? undefined + : num(rawHit.failure_detail_observed_duration_ms), + screenshot_key: rawHit.failure_detail_screenshot_key + ? str(rawHit.failure_detail_screenshot_key) + : undefined, + }; +} + export function mapRunDetail(rawHit: Record): SyntheticRunDetail | null { if (!rawHit) return null; const base = mapRun({ ts: rawHit.ts ?? rawHit._timestamp, status: rawHit.status, duration: rawHit.duration ?? rawHit.response_time_ms, + // Forwarded so the drawer can show init cost and queue delay from the same + // mapper the list uses, rather than re-deriving them (C4, C5). + scheduled_ts: rawHit.scheduled_ts, + started_ts: rawHit.started_ts, + init_ms: rawHit.init_ms, + status_reason: rawHit.status_reason, + error_source: rawHit.error_source, location: rawHit.location, device: rawHit.device, engine: rawHit.engine, @@ -706,17 +1609,41 @@ export function mapRunDetail(rawHit: Record): SyntheticRunDetai executionId: str(rawHit.execution_id), triggerType: str(rawHit.trigger_type), monitorName: str(rawHit.synthetics_name), - attempts: num(rawHit.attempt), + // The field is `attempts`; reading `attempt` returned undefined on every + // record, so the count rendered as 0 and the retry chip never appeared. + // `retry_history` is the fallback — with every attempt recorded, its length + // is the same number. + // `parseJsonArray` on the fallback too — `retry_history` arrives as a JSON + // string, so `Array.isArray` made this branch dead for every real record. + attempts: num(rawHit.attempts) || parseJsonArray(rawHit.retry_history).length, failedStep: rawHit.failed_step ? str(rawHit.failed_step) : (rawStepsArr.find((s: any) => s.status === "fail" || s.status === "failed")?.step_id ?? null), recordedSteps: Array.isArray(rawRecordedSteps) ? (rawRecordedSteps as RecordedStep[]) : [], + // `skipped` is a real outcome, not a failure. An `optional` step exists + // precisely because it may not be there — a cookie banner, a one-time + // popup — and collapsing it to `fail` reported a correctly-skipped step as + // a broken one, which is the opposite of what the flow-control feature is + // for. The type has always allowed all three; only this mapper did not. lastAttemptSteps: rawStepsArr.map((s: any) => ({ ...s, - status: s.status === "ok" || s.status === "passed" ? "ok" : ("fail" as const), + status: + s.status === "ok" || s.status === "passed" + ? ("ok" as const) + : s.status === "skipped" + ? ("skipped" as const) + : ("fail" as const), })), - retryHistory: [], + // The probe writes retry_history on every failed run; the mapper discarded + // it before any component could read it. A step that failed once and passed + // on the next attempt is transient by definition, and this is the only place + // that fact survives. + retryHistory: mapRetryHistory(rawHit.retry_history), + failureDetail: mapFailureDetail(flattenedFailureDetail(rawHit), rawHit.trace_key), + evidenceByStep: mapEvidence(rawHit.evidence_by_step), + evidenceKey: rawHit.evidence_key ? str(rawHit.evidence_key) : null, + evidenceTruncated: !!rawHit.evidence_truncated, network: null, webVitals: null, traceKey: rawHit.trace_key ? str(rawHit.trace_key) : null, @@ -863,10 +1790,41 @@ function timeBucketKey(tsMs: number, bucketMs: number): number { return Math.floor(tsMs / bucketMs) * bucketMs; } +/** + * The selector to show for a recorded step, whichever schema version it uses. + * + * A v1 step has one `selector`. A v2 step has a locator bundle instead, so + * reading `selector` alone leaves every v2 run showing empty selectors in + * results — a regression that would look like missing data rather than a schema + * mismatch (spec P2.5.6). + * + * A pinned `user_override` wins, because that is the locator the run actually + * used; otherwise it is the primary candidate, which is what the run would have + * started from. + */ +export function effectiveSelector(step: Record): string | null { + const pinned = step?.locator?.user_override; + if (pinned?.value) return str(pinned.value); + const primary = step?.locator?.candidates?.[0]; + if (primary?.value) return str(primary.value); + return step?.selector ? str(step.selector) : null; +} + export function aggregateStepStats( rawHits: Record[], startMicros: number, endMicros: number, + /** step_id → definition, from `buildStepDefsSql` + `foldStepDefs`. Built once + * for the whole window rather than re-parsed on every row (P1a). */ + stepDefsFromQuery?: Map, + /** Per-execution retry attribution from `buildRetryAttributionSql` (C7). + * When supplied, `retry_history` is neither selected nor parsed: the steps + * that failed in an earlier attempt come from the `retry_step_ids` column, + * which is three scalars instead of ~1 KB per attempt per row. */ + retryAttribution?: RetryAttributionSummary, + /** The `LIMIT` the tally query ran with, so the result can say whether the + * cap bound rather than the time range (P2a). */ + rowLimit?: number, ): StepStatsResult { const stepAcc = new Map(); const failureInstances: StepFailureInstance[] = []; @@ -889,22 +1847,31 @@ export function aggregateStepStats( const runTsMs = runTimestamp / 1000; const bucketKey = timeBucketKey(runTsMs, bucketMs); - const recordedSteps = parseJsonArray(hit.recorded_steps) as any[]; + const recordedSteps = stepDefsFromQuery ? [] : (parseJsonArray(hit.recorded_steps) as any[]); const lastAttemptSteps = parseJsonArray(hit.last_attempt_steps) as any[]; const retryHistory = parseJsonArray(hit.retry_history) as any[]; - // Build a lookup: step_id → { name, selector } from recorded_steps - const stepDefs = new Map(); - for (const rs of recordedSteps) { - stepDefs.set(str(rs.id), { - name: str(rs.name) || str(rs.id), - selector: rs.selector ? str(rs.selector) : null, - }); + // step_id → { name, selector }. Supplied by the caller from a bounded + // query (P1a); only parsed per row when a caller did not supply one. + let stepDefs = stepDefsFromQuery; + if (!stepDefs) { + stepDefs = new Map(); + for (const rs of recordedSteps) { + stepDefs.set(str(rs.id), { + name: str(rs.name) || str(rs.id), + selector: effectiveSelector(rs), + }); + } } - // Build prior-attempt step statuses for flaky detection + // Build prior-attempt step statuses for flaky detection. const priorStatuses = new Map(); - if (attempts > 1 && retryHistory.length > 0) { + const attributed = retryAttribution?.byExecution.get(executionId); + if (attributed) { + // `retry_step_ids` already IS "steps that failed in some attempt", which + // is exactly what this map holds — no blob to parse. + for (const sid of attributed) priorStatuses.set(sid, "fail"); + } else if (attempts > 1 && retryHistory.length > 0) { for (const retry of retryHistory) { const retrySteps = Array.isArray(retry.steps) ? retry.steps : []; for (const rs of retrySteps as any[]) { @@ -919,10 +1886,14 @@ export function aggregateStepStats( } const processedSteps = new Set(); + // id → the final attempt's step, so the sparkline pass below is a lookup + // rather than a scan per accumulator (X2). + const stepsById = new Map(); for (const step of lastAttemptSteps as any[]) { const stepId = str(step.step_id ?? step.id); processedSteps.add(stepId); + stepsById.set(stepId, step); const def = stepDefs.get(stepId); const stepName = def?.name ?? stepId; @@ -937,7 +1908,10 @@ export function aggregateStepStats( const isFlaky = attempts > 1 && priorFailed && isOk; // ── Accumulate step stats ──────────────────────────────────── - let acc = stepAcc.get(stepName); + // Keyed by step_id, never by name (X1): `recorded_steps` is historical, + // so a renamed step would split into two rows and two steps sharing a + // name would merge into one. + let acc = stepAcc.get(stepId); if (!acc) { acc = { name: stepName, @@ -952,7 +1926,11 @@ export function aggregateStepStats( browserMap: new Map(), locationMap: new Map(), }; - stepAcc.set(stepName, acc); + stepAcc.set(stepId, acc); + } else { + // Rows arrive newest-first, so the first definition seen is the newest. + // Keep it; later (older) rows must not relabel the row backwards. + if (!acc.name) acc.name = stepName; } acc.totalExecutions++; @@ -983,10 +1961,10 @@ export function aggregateStepStats( if (isFlaky) lStats.flaky++; // ── Accumulate trend data ───────────────────────────────────── - let tAcc = trendAcc.get(stepName); + let tAcc = trendAcc.get(stepId); if (!tAcc) { tAcc = { stepName, bucketMap: new Map() }; - trendAcc.set(stepName, tAcc); + trendAcc.set(stepId, tAcc); } let bEntry = tAcc.bucketMap.get(bucketKey); if (!bEntry) { @@ -1023,7 +2001,7 @@ export function aggregateStepStats( // Step was in recorded_steps and failed in a prior attempt but isn't in // last_attempt_steps — could be a flaky step that resolved on retry. const stepName = def.name || stepId; - let acc = stepAcc.get(stepName); + let acc = stepAcc.get(stepId); if (!acc) { acc = { name: stepName, @@ -1038,34 +2016,33 @@ export function aggregateStepStats( browserMap: new Map(), locationMap: new Map(), }; - stepAcc.set(stepName, acc); + stepAcc.set(stepId, acc); } + // X3 — this step ran in THIS execution (it failed on an earlier attempt); + // it simply did not reach the final attempt's step list. Counting the + // flake without counting the execution let Flaky Rate exceed 100%. + acc.totalExecutions++; acc.flakyCount++; } // ── Update recent-run statuses for sparklines ─────────────────── - for (const acc of stepAcc.values()) { - // Determine this run's status for this step - const processedInRun = lastAttemptSteps.some((s: any) => { - const sid = str(s.step_id ?? s.id); - const def = stepDefs.get(sid); - return (def?.name ?? sid) === acc.name; - }); + // X2 — this used to call `lastAttemptSteps.some(...)` for every accumulator + // on every row: O(steps²) per row, ~2M iterations on a 20-step journey over + // 5000 rows and ~50M on a 100-step one, on the main thread. `processedSteps` + // is already the set of ids seen in this row, so the membership test is a + // Set lookup. + for (const [stepIdKey, acc] of stepAcc) { + const processedInRun = processedSteps.has(stepIdKey); if (processedInRun) { if (acc.recentRunStatuses.length >= MAX_SPARKLINE_POINTS) { acc.recentRunStatuses.shift(); } - const stepFromRun = (lastAttemptSteps as any[]).find((s: any) => { - const sid = str(s.step_id ?? s.id); - const def = stepDefs.get(sid); - return (def?.name ?? sid) === acc.name; - }); + const stepFromRun = stepsById.get(stepIdKey); if (stepFromRun) { const runStepStatus = str(stepFromRun.status); const isRunOk = runStepStatus === "ok" || runStepStatus === "passed"; - const priorFailedForStep = - priorStatuses.get(str(stepFromRun.step_id ?? stepFromRun.id)) === "fail"; + const priorFailedForStep = priorStatuses.get(stepIdKey) === "fail"; if (!isRunOk) { acc.recentRunStatuses.push("fail"); } else if (priorFailedForStep && attempts > 1) { @@ -1085,7 +2062,10 @@ export function aggregateStepStats( const stepDurations: StepDuration[] = []; const flakySteps: FlakyStep[] = []; - for (const [name, acc] of stepAcc) { + // Keyed by step_id since X1; the display name lives on the accumulator and is + // the newest definition seen for that id. + for (const [stepId, acc] of stepAcc) { + const name = acc.name || stepId; const failRate = acc.totalExecutions > 0 ? Math.round((acc.failures / acc.totalExecutions) * 1000) / 10 : 0; const flakyRate = @@ -1105,7 +2085,7 @@ export function aggregateStepStats( const recentRates = acc.recentRunStatuses.map((s) => (s === "fail" || s === "flaky" ? 1 : 0)); stepGroups.push({ - key: `step-${name}`, + key: `step-${stepId}`, name, sub: acc.selector, failRate: failRateFull, @@ -1173,7 +2153,8 @@ export function aggregateStepStats( let othersAcc: InternalTrendAccumulator | null = null; const trendBuckets: TrendBucket[] = []; - for (const [stepName, tAcc] of trendAcc) { + for (const [, tAcc] of trendAcc) { + const stepName = tAcc.stepName; if (topSteps.includes(stepName)) { for (const [bk, entry] of tAcc.bucketMap) { trendBuckets.push({ @@ -1217,5 +2198,14 @@ export function aggregateStepStats( flakySteps, trendBuckets, failureInstances, + // P2a — `sorted` is oldest-first, so its ends ARE the covered window. + coverage: { + executions: sorted.length, + fromMs: sorted.length ? num(sorted[0].ts) / 1000 : 0, + toMs: sorted.length ? num(sorted[sorted.length - 1].ts) / 1000 : 0, + // Equality, not `>=`: the query asked for exactly this many and got them, + // so there is no way to know how many more the range held. + truncated: rowLimit !== undefined && sorted.length >= rowLimit, + }, }; } diff --git a/web/src/composables/useSyntheticResults.spec.ts b/web/src/composables/useSyntheticResults.spec.ts index 57d384f038..190d9a52db 100644 --- a/web/src/composables/useSyntheticResults.spec.ts +++ b/web/src/composables/useSyntheticResults.spec.ts @@ -28,9 +28,15 @@ vi.mock("vuex", () => ({ })), })); +// Hoisted: `vi.mock` factories run before module init, so a plain `const` here +// would still be undefined when the factory closes over it. +const { getStreamMock } = vi.hoisted(() => ({ + getStreamMock: vi.fn(), +})); + vi.mock("@/composables/useStreams", () => ({ default: () => ({ - getStream: vi.fn().mockRejectedValue(new Error("no stream")), + getStream: getStreamMock, }), })); @@ -39,6 +45,9 @@ import useSyntheticResults from "./useSyntheticResults"; describe("useSyntheticResults", () => { beforeEach(() => { vi.clearAllMocks(); + // Schema unknown by default: optional columns fall back to literals, which + // is the shape most of these cases assert against. + getStreamMock.mockRejectedValue(new Error("no stream")); }); it("should map raw search responses into typed state via the adapters", async () => { @@ -75,15 +84,65 @@ describe("useSyntheticResults", () => { expect(hasLoadedOnce.value).toBe(true); }); - it("should issue five scoped queries against the logs page type", async () => { + it("issues one query per Overview panel, all against the logs page type", async () => { executeQuery.mockResolvedValue([]); const { fetchAll } = useSyntheticResults(); await fetchAll("mon-1", 1, 100); - // KPI, last-run, histogram, runs, steps (via stream) - expect(executeQuery).toHaveBeenCalledTimes(5); + + // KPI, last-run, histogram, runs — and nothing for the Steps tab. The step + // aggregation is the most expensive request the page can make and the Steps + // tab is the least-visited one, so it is not part of the Overview load; + // counting calls is the only thing that notices if it creeps back in. + expect(executeQuery).toHaveBeenCalledTimes(4); for (const call of executeQuery.mock.calls) { expect(call[3]).toBe("logs"); } + expect(executeQuery.mock.calls.some((c) => String(c[0]).includes("last_attempt_steps"))).toBe( + false, + ); + }); + + it("issues the step queries only from fetchSteps", async () => { + executeQuery.mockResolvedValue([]); + const { fetchSteps } = useSyntheticResults(); + await fetchSteps("mon-1", 1, 100); + + // The step tally, and the step DEFINITIONS. The latter is a second, bounded + // query rather than a column on the tally: `recorded_steps` is ~4 KB per row + // and near-identical within a config version, so selecting it across the + // 5000-row aggregation shipped the same payload thousands of times. + // + // `getStream` is mocked to reject here, so the schema is unknown and the + // retry-attribution query does not fire — see the case below. + expect(executeQuery).toHaveBeenCalledTimes(2); + for (const call of executeQuery.mock.calls) { + expect(call[3]).toBe("logs"); + } + expect(executeQuery.mock.calls.some((c) => String(c[0]).includes("last_attempt_steps"))).toBe( + true, + ); + }); + + it("adds the retry-attribution query only when the column exists", async () => { + // `retry_step_ids` replaces reading `retry_history` on every row of the step + // tally, so it is additive-then-subtractive: the third query buys back a + // blob column. Gated on the schema because a stream that has never recorded + // a retry does not have the field, and naming an absent column is rejected + // outright by the search API. + executeQuery.mockResolvedValue([]); + getStreamMock.mockResolvedValueOnce({ + schema: [{ name: "attempts" }, { name: "retry_history" }, { name: "retry_step_ids" }], + }); + + const { fetchSteps } = useSyntheticResults(); + await fetchSteps("mon-1", 1, 100); + + const sql = executeQuery.mock.calls.map((c) => String(c[0])); + expect(sql.some((q) => q.includes("retry_step_ids") && q.includes("attempts > 1"))).toBe(true); + // And the tally stops selecting the blob it replaces. + const tally = sql.find((q) => q.includes("last_attempt_steps")); + expect(tally).toBeDefined(); + expect(tally).not.toContain("retry_history"); }); it("should not query when monitorId or the time range is missing", async () => { diff --git a/web/src/composables/useSyntheticResults.ts b/web/src/composables/useSyntheticResults.ts index 7a64da5a04..169c5c5b8f 100644 --- a/web/src/composables/useSyntheticResults.ts +++ b/web/src/composables/useSyntheticResults.ts @@ -21,9 +21,13 @@ import { bucketInterval, buildHistogramSql, buildKpiSql, + buildRetryAttributionSql, + foldRetryAttribution, buildLastRunSql, buildRunsSql, buildRunsWithStepsSql, + buildStepDefsSql, + foldStepDefs, buildRunDetailSql, buildProtocolRunDetailSql, mapHistogram, @@ -50,6 +54,8 @@ const EMPTY_KPI: SyntheticKpi = { errorRuns: 0, totalRuns: 0, retriedRuns: 0, + flakyExecutions: 0, + degradedExecutions: 0, lastRunStatus: null, lastRunAt: null, }; @@ -99,6 +105,9 @@ export function useSyntheticResults() { flakySteps: [], trendBuckets: [], failureInstances: [], + // Same shape `emptyStepStats()` returns; the initial value was missed when + // `coverage` was added, and only `tsconfig.app.json` is strict enough to say so. + coverage: { executions: 0, fromMs: 0, toMs: 0, truncated: false }, }); // ── Stream schema fields ───────────────────────────────────────────────── @@ -108,13 +117,42 @@ export function useSyntheticResults() { // is absent until a run has failed, …) and the search API rejects queries // naming absent fields. Query builders take this set and substitute // literals for missing columns. getStream caches, so repeat calls are cheap. + /** + * Field names present in the stream schema. + * + * On failure this returns an EMPTY set, which makes every optional column + * select a typed literal instead of its name. That is the only option that + * cannot fail — naming a column the schema lacks is rejected outright by the + * search API, and the schema genuinely lacks `status_reason` until some run + * has been a `warning`. + * + * The cost is that a schema-fetch failure is indistinguishable from a stream + * that has none of these fields: `init_ms` reads 0, `attempts` reads 0 and + * `retry_history` reads '', so the run detail renders with no init chip, no + * queue delay and no attempts strip — a fetch failure presented as a run that + * simply had none of those things. + * + * Hence the log. It is the only signal that the degraded render is a failure + * rather than the data, and it cost real debugging time to work that out once. + */ async function fetchSchemaFields(): Promise> { try { const stream: any = await getStream(SYNTHETIC_RESULTS_STREAM, "logs", true); - return new Set(((stream?.schema ?? []) as { name: string }[]).map((f) => f.name)); - } catch { - // Schema not available — an empty set selects literals for every - // optional column, which cannot fail. + const fields = ((stream?.schema ?? []) as { name: string }[]).map((f) => f.name); + if (!fields.length) { + // eslint-disable-next-line no-console + console.warn( + "[synthetics] stream schema returned no fields; optional columns will render as empty", + ); + } + return new Set(fields); + } catch (e: unknown) { + // eslint-disable-next-line no-console + console.warn( + "[synthetics] stream schema unavailable — optional columns (init_ms, attempts, " + + "retry_history, …) will render as empty, NOT as absent data:", + e, + ); return new Set(); } } @@ -153,24 +191,76 @@ export function useSyntheticResults() { ): Promise { try { let hasRetryHistory = false; + let hasRetryAttribution = false; + let hasStatusReason = false; try { const stream: any = await getStream(SYNTHETIC_RESULTS_STREAM, "logs", true); const schema: { name: string }[] = stream?.schema ?? []; hasRetryHistory = schema.some((f) => f.name === "retry_history"); + hasRetryAttribution = schema.some((f) => f.name === "retry_step_ids"); + hasStatusReason = schema.some((f) => f.name === "status_reason"); } catch { // Schema not available — omit retry_history, which is safe. } + // C7 — once the probe writes `retry_step_ids`, the flaky column is + // answered by three scalars on the rows that actually retried, so the + // ~1 KB-per-attempt `retry_history` blob stops being fetched across all + // 5000 rows. Until then the old path still works, unchanged. + const useAttribution = hasRetryAttribution; + const selectRetryHistory = hasRetryHistory && !useAttribution; + /** Set when the attribution query failed, so its results are not treated + * as "nothing retried". */ + let attributionFailed = false; const STEP_RUNS_LIMIT = 5000; - const hits: Record[] = await executeQuery( - buildRunsWithStepsSql(monitorId, STEP_RUNS_LIMIT, hasRetryHistory), - startTime, - endTime, - "logs", - ); + // Two queries rather than one (P1a): the wide tally without + // `recorded_steps`, and a bounded fetch of the step definitions. Selecting + // the definitions on all 5000 rows shipped the same ~4 KB blob 5000 times + // — roughly 60% of this panel's payload. + const STEP_DEFS_LIMIT = 100; + const [hits, defHits, retryHits] = await Promise.all([ + executeQuery( + buildRunsWithStepsSql(monitorId, STEP_RUNS_LIMIT, selectRetryHistory), + startTime, + endTime, + "logs", + ) as Promise[]>, + executeQuery( + buildStepDefsSql(monitorId, STEP_DEFS_LIMIT), + startTime, + endTime, + "logs", + ) as Promise[]>, + useAttribution + ? (executeQuery( + buildRetryAttributionSql(monitorId, STEP_RUNS_LIMIT, hasStatusReason), + startTime, + endTime, + "logs", + ).catch((e: unknown) => { + // Isolated deliberately. These three queries share a Promise.all, + // so an unhandled rejection here emptied the ENTIRE Steps tab — + // Fail Rate, durations and all — to report one missing column. + // Degrade the flaky column instead, and say so rather than + // rendering a silent zero. + // eslint-disable-next-line no-console + console.warn("[synthetics] retry attribution query failed:", e); + attributionFailed = true; + return [] as Record[]; + }) as Promise[]>) + : Promise.resolve([] as Record[]), + ]); + const stepDefs = foldStepDefs(defHits); if (!hits.length) return emptyStepStats(); - return aggregateStepStats(hits, startTime, endTime); + return aggregateStepStats( + hits, + startTime, + endTime, + stepDefs, + useAttribution && !attributionFailed ? foldRetryAttribution(retryHits) : undefined, + STEP_RUNS_LIMIT, + ); } catch { return emptyStepStats(); } @@ -184,16 +274,24 @@ export function useSyntheticResults() { flakySteps: [], trendBuckets: [], failureInstances: [], + coverage: { executions: 0, fromMs: 0, toMs: 0, truncated: false }, }; } + /** + * Loads everything the Overview tab needs. + * + * Steps are deliberately NOT part of this: the step aggregation reads the + * REST /runs endpoint row by row and is the most expensive query on the page, + * while the Steps tab is the one the fewest visits ever open. Callers drive it + * separately through `fetchSteps` when the tab is actually in view. + */ async function fetchAll(monitorId: string, startTime: number, endTime: number): Promise { if (!monitorId || !startTime || !endTime) return; loading.value = true; kpiLoading.value = true; histogramLoading.value = true; runsLoading.value = true; - stepsLoading.value = true; error.value = null; // Clear per-group errors on each fresh fetch so a successful retry @@ -201,19 +299,24 @@ export function useSyntheticResults() { kpiError.value = null; histogramError.value = null; runsError.value = null; - stepsError.value = null; try { const interval = bucketInterval(endTime - startTime); const schemaFields = await fetchSchemaFields(); const hasAttemptsField = schemaFields.has("attempts"); + const hasStatusReasonField = schemaFields.has("status_reason"); // Group 1: KPI + last-run — both feed KPI cards. Resolves // independently so the KPI section renders as soon as these // fast queries complete, without waiting for the runs list. const kpiPromise = Promise.all([ - executeQuery(buildKpiSql(monitorId, hasAttemptsField), startTime, endTime, "logs"), + executeQuery( + buildKpiSql(monitorId, hasAttemptsField, hasStatusReasonField), + startTime, + endTime, + "logs", + ), executeQuery(buildLastRunSql(monitorId), startTime, endTime, "logs"), ]) .then(([kpiRows, lastRunRows]) => { @@ -268,23 +371,9 @@ export function useSyntheticResults() { runsHasLoadedOnce.value = true; }); - // Group 4: Steps — fetched via REST /runs API because the log - // stream doesn't carry the step-level JSON fields. - const stepsPromise = fetchAndAggregateSteps(monitorId, startTime, endTime) - .then((stats) => { - stepStats.value = stats; - }) - .catch((e: unknown) => { - stepsError.value = e instanceof Error ? e.message : String(e ?? "Steps query failed"); - }) - .finally(() => { - stepsLoading.value = false; - stepsHasLoadedOnce.value = true; - }); - // Wait for all to settle so callers that await fetchAll still // get a meaningful completion signal. - await Promise.all([kpiPromise, histogramPromise, runsPromise, stepsPromise]); + await Promise.all([kpiPromise, histogramPromise, runsPromise]); } catch (e: unknown) { error.value = e instanceof Error ? e.message : "Failed to load results"; kpi.value = { ...EMPTY_KPI }; @@ -356,13 +445,22 @@ export function useSyntheticResults() { } } + /** + * Step aggregation for the Steps tab — fetched via the REST /runs API because + * the log stream doesn't carry the step-level JSON fields. + * + * Called on its own rather than from `fetchAll`, so the Steps tab pays for + * this only when it is opened or its window changes underneath it. + */ async function fetchSteps(monitorId: string, startTime: number, endTime: number): Promise { if (!monitorId || !startTime || !endTime) return; stepsLoading.value = true; + stepsError.value = null; try { stepStats.value = await fetchAndAggregateSteps(monitorId, startTime, endTime); - } catch { + } catch (e: unknown) { stepStats.value = emptyStepStats(); + stepsError.value = e instanceof Error ? e.message : String(e ?? "Steps query failed"); } finally { stepsLoading.value = false; stepsHasLoadedOnce.value = true; diff --git a/web/src/composables/useSyntheticsRecorder.spec.ts b/web/src/composables/useSyntheticsRecorder.spec.ts index bd55da84a1..e3f7765c19 100644 --- a/web/src/composables/useSyntheticsRecorder.spec.ts +++ b/web/src/composables/useSyntheticsRecorder.spec.ts @@ -54,6 +54,9 @@ function emitStreamEvent(payload: Record) { ); } +/** Mirrors REPLAY_TIMEOUT_MS in the composable — the replay watchdog window. */ +const REPLAY_TIMEOUT_MS = 15 * 60 * 1000; + /** Let the 500 ms probe delay + any pending microtasks settle. */ async function settleProbeDelay() { await vi.advanceTimersByTimeAsync(500); @@ -129,6 +132,9 @@ describe("useSyntheticsRecorder", () => { // Verify the command was posted correctly const cmd = getLastCommand(); expect(cmd).toMatchObject({ action: "startRecording", targetUrl: "https://app.test/login" }); + // The field existed on the command type and was never populated, so every + // recording silently fell back to Playwright's `data-testid`. + expect(cmd.testIdAttr).toBe("data-test"); // Stream steps const browserSteps: WireStep[] = [ @@ -193,6 +199,32 @@ describe("useSyntheticsRecorder", () => { }); }); + it("sends the configured test-id attribute when one is given", async () => { + const r = useSyntheticsRecorder(); + const promise = r.startRecording("https://app.test", "data-qa"); + + await settleProbeDelay(); + respondToLastCommand({ success: true }); + await promise; + + // An application on data-qa/data-cy/data-pw produced NO test-attribute + // candidates before this — upstream's hardcoded fallback list covers only + // data-testid, data-test-id and data-test, so their strongest attribute + // was stored as plain css, rank 3, behind text. + expect(getLastCommand().testIdAttr).toBe("data-qa"); + }); + + it("falls back to the O2 default when no attribute is given", async () => { + const r = useSyntheticsRecorder(); + const promise = r.startRecording("https://app.test", ""); + + await settleProbeDelay(); + respondToLastCommand({ success: true }); + await promise; + + expect(getLastCommand().testIdAttr).toBe("data-test"); + }); + // ── stopRecording ────────────────────────────────────────────────────── describe("stopRecording", () => { @@ -341,13 +373,49 @@ describe("useSyntheticsRecorder", () => { expect(sentCmd).toMatchObject({ action: "stopReplay" }); }); - it("should return null when the command times out", async () => { + it("should stay running past the one-shot command timeout while steps stream in", async () => { + // Regression: a real journey takes far longer than COMMAND_TIMEOUT_MS. + // The extension only answers the `replay` command once the whole journey + // has finished, so a blanket short timeout made the UI fall back to + // "idle" a few steps in while the extension kept replaying. + const journey: WireStep[] = [ + { id: "s1", action: "navigate", url: "https://x.test" }, + { id: "s2", action: "click", selector: "#login" }, + { id: "s3", action: "click", selector: "#logout" }, + ]; + const r = useSyntheticsRecorder(); + const promise = r.replay(journey); + + await settleProbeDelay(); + + // Two steps land inside the first four seconds. + await vi.advanceTimersByTimeAsync(2000); + emitStreamEvent({ method: "stepReplayResult", stepId: "s1", passed: true, duration_ms: 900 }); + await vi.advanceTimersByTimeAsync(2000); + emitStreamEvent({ method: "stepReplayResult", stepId: "s2", passed: true, duration_ms: 800 }); + + // Past the one-shot timeout the replay is still in flight. + await vi.advanceTimersByTimeAsync(5000); + expect(r.replayPhase.value).toBe("running"); + expect(r.isReplaying.value).toBe(true); + + // The extension finally answers, 30s in. + await vi.advanceTimersByTimeAsync(21000); + emitStreamEvent({ method: "stepReplayResult", stepId: "s3", passed: true, duration_ms: 700 }); + respondToLastCommand({ success: true, passed: true }); + + expect(await promise).toEqual({ success: true, passed: true }); + expect(r.replayPhase.value).toBe("passed"); + expect(r.isReplaying.value).toBe(false); + expect(r.stepResults.size).toBe(3); + }); + + it("should give up when the extension never answers the replay command", async () => { const r = useSyntheticsRecorder(); const promise = r.replay(steps); - // Let probe delay + command timeout fire await settleProbeDelay(); - await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(REPLAY_TIMEOUT_MS); const res = await promise; expect(res).toBeNull(); diff --git a/web/src/composables/useSyntheticsRecorder.ts b/web/src/composables/useSyntheticsRecorder.ts index 51e1f119d3..bb47da34a8 100644 --- a/web/src/composables/useSyntheticsRecorder.ts +++ b/web/src/composables/useSyntheticsRecorder.ts @@ -17,6 +17,7 @@ import type { WireStep, } from "@/types/synthetics"; import { substituteVariables } from "@/utils/synthetics/mapRecordedStep"; +import { DEFAULT_TEST_ID_ATTR } from "@/constants/synthetics"; /** * Encapsulates all communication with the OpenObserve Extension (playwright-crx) @@ -53,6 +54,18 @@ const useSyntheticsRecorder = () => { const BRIDGE_CHANNEL = "oo-bridge"; const COMMAND_TIMEOUT_MS = 4000; + // `replay` is the one command the extension answers only when the whole + // journey has finished — the service worker resolves it from handleReplay, + // after the last step. Racing it against COMMAND_TIMEOUT_MS made the UI fall + // back to "idle" four seconds in while the extension kept replaying in its + // own window. A single step alone may legitimately take 60 s (the flat + // preview timeout, P1.R.1), so this is not a journey bound — it is a + // last-resort watchdog for a bridge that died without answering. Sized to + // LEASE_SECS = 900 (D-9), the outer bound one attempt is ever contained in; + // anything shorter would make the preview stricter than production (X-8.1). + // See docs/synthetics/reliability/synthetics-recorded-test-reliability-spec.md. + const REPLAY_TIMEOUT_MS = 15 * 60 * 1000; + let nonceCounter = 0; function nextNonce(): string { return `${Date.now()}_${nonceCounter++}_${Math.random().toString(36).slice(2, 8)}`; @@ -117,19 +130,32 @@ const useSyntheticsRecorder = () => { bridgeDataHandler?.(msg); }); - /** One-shot command via postMessage. Resolves `null` when the extension is unreachable. */ - function sendCommand(command: RecorderCommand): Promise { + /** + * One-shot command via postMessage. Resolves `null` when the extension is + * unreachable. `timeoutMs` is how long to wait for the ack — long-running + * commands (`replay`) pass their own window. + */ + function sendCommand( + command: RecorderCommand, + timeoutMs: number = COMMAND_TIMEOUT_MS, + ): Promise { const nonce = nextNonce(); - const timeout = new Promise((resolve) => - setTimeout(() => { + let timer: ReturnType; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { pendingCommands.delete(nonce); resolve(null); - }, COMMAND_TIMEOUT_MS), - ); + }, timeoutMs); + }); const promise = new Promise((resolve) => { - pendingCommands.set(nonce, resolve); + pendingCommands.set(nonce, (response) => { + // Release the watchdog — a replay's is 15 minutes long, and leaving one + // armed per replay would keep the timer alive well past the answer. + clearTimeout(timer); + resolve(response); + }); }); window.postMessage( { ch: BRIDGE_CHANNEL, dir: "to-ext", nonce, msg: { type: "synthetics-command", command } }, @@ -172,7 +198,10 @@ const useSyntheticsRecorder = () => { const { payload } = msg; switch (payload.method) { case "setActions": - liveSteps.value = mapWireSteps(payload.browserSteps); + // Live capture: keep the extension's own step for replay fidelity. These + // wires carry fields the v2 schema cannot store (options, modifiers, + // button, position, framePath). + liveSteps.value = mapWireSteps(payload.browserSteps, { preserveWire: true }); break; case "recordingStarted": currentUrl.value = payload.url; @@ -197,6 +226,10 @@ const useSyntheticsRecorder = () => { durationMs: payload.duration_ms, error: payload.error, structuredError: payload.structuredError, + // X-8.2: the player reports what it could not reproduce. Dropping this + // made every such divergence silent — including a skipped step that + // would otherwise read as a pass. + fidelity: payload.fidelity, }); activeStepId.value = null; break; @@ -227,7 +260,7 @@ const useSyntheticsRecorder = () => { * `targetUrl` is kept only for the local recording banner — the extension * command itself takes no URL. */ - async function startRecording(targetUrl: string): Promise { + async function startRecording(targetUrl: string, testIdAttr?: string): Promise { error.value = ""; liveSteps.value = []; currentUrl.value = targetUrl; @@ -247,7 +280,17 @@ const useSyntheticsRecorder = () => { isRecording.value = false; }; - const res = await sendCommand({ action: "startRecording", targetUrl }); + // The extension defaults to Playwright's `data-testid` when this is absent, + // and it was absent on every recording ever made — the field existed on the + // command type but nothing populated it. O2 markup uses `data-test`, which + // only produced test-attribute candidates because upstream's generator + // happens to carry a hardcoded fallback list containing it. An app on + // `data-qa` or `data-cy` got none at all, silently. + const res = await sendCommand({ + action: "startRecording", + targetUrl, + testIdAttr: testIdAttr || DEFAULT_TEST_ID_ATTR, + }); if (!res?.success) { console.debug("Disconnect ---", res); error.value = res?.error || "Failed to start recording."; @@ -347,11 +390,14 @@ const useSyntheticsRecorder = () => { // intercept property access — postMessage structured clone sees the proxy, // not the underlying object, and silently drops all fields. const plainSteps = JSON.parse(JSON.stringify(resolvedSteps)) as WireStep[]; - const res = await sendCommand({ - action: "replay", - steps: plainSteps, - targetUrl, - }); + const res = await sendCommand( + { + action: "replay", + steps: plainSteps, + targetUrl, + }, + REPLAY_TIMEOUT_MS, + ); isReplaying.value = false; replayResult.value = res; if (res) { diff --git a/web/src/constants/synthetics.spec.ts b/web/src/constants/synthetics.spec.ts new file mode 100644 index 0000000000..deaddcd30c --- /dev/null +++ b/web/src/constants/synthetics.spec.ts @@ -0,0 +1,72 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import type { StepAction } from "@/types/synthetics"; +import { + ACTION_ICONS, + ACTION_LABELS, + RETIRED_ACTIONS, + actionOptions, + isRetiredAction, +} from "./synthetics"; + +describe("synthetics action vocabulary", () => { + // Spec X-9 / T1-9. Upstream Playwright's recorder action model (ActionName in + // @recorder/actions) has no hover/scroll/wait/screenshot, so the recorder has + // never emitted one and the player has never been able to replay one. They + // entered journeys only through this picker — and the moment an author used + // one, replay aborted before step 1. + it("does not offer retired actions in the step picker", () => { + const offered = actionOptions.map((o) => o.value); + for (const retired of RETIRED_ACTIONS) { + expect(offered).not.toContain(retired); + } + }); + + // The picker offers the version-2 vocabulary, which is exactly Playwright's + // recorder action model minus what a monitor cannot use. `check`/`uncheck` + // joined it when the recorder stopped collapsing a checkbox interaction to a + // click (X-9.3), and `upload` when a file input stopped being surfaced as a + // `type` step. + it("offers exactly the actions the player and probe can both execute", () => { + expect(actionOptions.map((o) => o.value).sort()).toEqual( + [ + "assert", + "check", + "click", + "navigate", + "press", + "select", + "type", + "uncheck", + "upload", + ].sort(), + ); + }); + + // Retired actions must still RENDER: stored monitors contain them (all five + // production monitors carry a legacy `wait`) and keep executing until + // migrated. Dropping their label/icon would break the editor for those. + it("still renders retired actions so existing monitors display correctly", () => { + for (const retired of RETIRED_ACTIONS) { + expect(ACTION_LABELS[retired]).toBeTruthy(); + expect(ACTION_ICONS[retired]).toBeTruthy(); + } + }); + + it("identifies retired actions", () => { + expect(isRetiredAction("wait")).toBe(true); + expect(isRetiredAction("hover")).toBe(true); + expect(isRetiredAction("scroll")).toBe(true); + expect(isRetiredAction("screenshot")).toBe(true); + expect(isRetiredAction("click")).toBe(false); + expect(isRetiredAction("navigate")).toBe(false); + }); + + it("every offered action has a label and an icon", () => { + for (const { value } of actionOptions) { + expect(ACTION_LABELS[value as StepAction]).toBeTruthy(); + expect(ACTION_ICONS[value as StepAction]).toBeTruthy(); + } + }); +}); diff --git a/web/src/constants/synthetics.ts b/web/src/constants/synthetics.ts index 04e928430e..099d2c3167 100644 --- a/web/src/constants/synthetics.ts +++ b/web/src/constants/synthetics.ts @@ -1,6 +1,6 @@ // Copyright 2026 OpenObserve Inc. -import type { StepAction, SelectorType, SyntheticCheckType } from "@/types/synthetics"; +import type { AssertionKind, StepAction, SyntheticCheckType } from "@/types/synthetics"; import type { IconName } from "@/lib/core/Icon/OIcon.icons"; // ── Action labels (capitalized) ────────────────────────────────────────── @@ -10,6 +10,9 @@ export const ACTION_LABELS: Record = { type: "Type", select: "Select", press: "Press", + check: "Check", + uncheck: "Uncheck", + upload: "Upload", hover: "Hover", scroll: "Scroll", wait: "Wait", @@ -24,6 +27,9 @@ export const ACTION_ICONS: Record = { type: "keyboard", select: "checklist", press: "keyboard", + check: "check-box", + uncheck: "toggle-off", + upload: "upload-file", hover: "touch-app", scroll: "swap-vert", wait: "hourglass-empty", @@ -36,37 +42,101 @@ export const SELECTOR_ACTIONS: readonly StepAction[] = [ "click", "type", "select", + "check", + "uncheck", + "upload", "hover", "assert", ]; +/** + * Actions whose step carries an author-editable value. + * + * `upload` is here because a recorded upload's file path is mapped into + * `step.value` and saved back out as `files` — omitting it meant the path was + * stored and replayed but had no input, so an author could neither see it nor + * change it. + * + * `assert` is deliberately absent: BrowserJourneyAssertion owns the expected + * value for an assert step, and a v2 payload drops `value` on assert + * (buildV2Steps `v2Value`). A second, generic Expected input took typing and + * silently discarded it at save. + */ export const VALUE_ACTIONS: readonly StepAction[] = [ "navigate", "type", "select", "press", + "upload", "scroll", "wait", - "assert", ]; +/** + * Actions retired from the authoring vocabulary (spec X-9). + * + * Upstream Playwright's recorder action model has no counterpart for any of + * these — `ActionName` in @recorder/actions omits them entirely — so the + * recorder has never emitted one and the player has never been able to replay + * one. They entered journeys only through this picker, and the moment an author + * used one, replay died before step 1. + * + * `scroll` additionally carries no information: Playwright scrolls an element + * into view before acting on it, and the probe silently no-ops the step — a + * false green. `screenshot` is redundant with the per-run capture setting. + * `wait` is the hard sleep this whole design exists to remove. + * + * Kept in ACTION_LABELS/ACTION_ICONS so existing monitors still RENDER; removed + * from the picker so no new journey can contain one. Stored monitors keep + * executing them until migrated (spec Q-10). + */ +export const RETIRED_ACTIONS: readonly StepAction[] = ["hover", "scroll", "wait", "screenshot"]; + +export function isRetiredAction(action: StepAction): boolean { + return RETIRED_ACTIONS.includes(action); +} + +// ── Assertion kinds (spec P5.1) ────────────────────────────────────────── +/** + * Closed set, mirroring the server's. The probe FAILS an unknown kind rather + * than passing it, so a typo that got past the UI would show up as every run + * failing rather than as an error at save time. + */ +export const ASSERTION_KINDS: readonly AssertionKind[] = [ + "element_visible", + "element_not_visible", + "element_text", + "url_matches", + "page_title", + "element_attribute", +]; + +/** The two visibility kinds ask "is it there?" — there is nothing to compare. */ +export function assertionNeedsExpected(kind: AssertionKind): boolean { + return kind !== "element_visible" && kind !== "element_not_visible"; +} + +export function assertionNeedsAttribute(kind: AssertionKind): boolean { + return kind === "element_attribute"; +} + +/** Kinds that describe the page rather than an element, so they need no locator. */ +export function isPageLevelAssertion(kind: AssertionKind): boolean { + return kind === "url_matches" || kind === "page_title"; +} + // ── Action dropdown options ────────────────────────────────────────────── -export const actionOptions = (Object.keys(ACTION_LABELS) as StepAction[]).map((a) => ({ - label: ACTION_LABELS[a], - value: a, -})); +export const actionOptions = (Object.keys(ACTION_LABELS) as StepAction[]) + .filter((a) => !isRetiredAction(a)) + .map((a) => ({ + label: ACTION_LABELS[a], + value: a, + })); -// ── Selector type options ──────────────────────────────────────────────── -export const SELECTOR_TYPE_OPTIONS: readonly { - label: string; - value: SelectorType; -}[] = [ - { label: "CSS", value: "CSS" }, - { label: "XPath", value: "XPath" }, - { label: "Text", value: "Text" }, - { label: "TestID", value: "TestID" }, - { label: "Role", value: "Role" }, -]; +// The selector-type picker (CSS / XPath / Text / TestID / Role) is gone with the +// v1 authoring path: a version-2 step names its element with a locator bundle, +// whose value carries its own engine prefix. `SelectorType` itself survives in +// types/synthetics.ts for liftJourney, which issue 006 owns. // ── Value field labels (action-specific) ───────────────────────────────── export const VALUE_LABELS: Record = { @@ -74,11 +144,31 @@ export const VALUE_LABELS: Record = { type: "Text to type", select: "Option", press: "Key", + upload: "File path", scroll: "To (px or selector)", wait: "Duration (ms)", - assert: "Expected", }; +// ── Per-step timeout bounds ────────────────────────────────────────────── +/** + * Mirrors the server's range check on a step's `timeout_ms` (spec P1.1.3: + * *"it validates into `100..=60_000`"*). + * + * Note the maximum EQUALS the navigate/assert category default + * (`NAV_ASSERT_TIMEOUT_MS`), so on those two actions an explicit timeout can only + * ever shorten the step — which is why the editor says so rather than leaving the + * below-default warning looking like a malfunction (SE-20). + */ +export const MIN_STEP_TIMEOUT_MS = 100; +export const MAX_STEP_TIMEOUT_MS = 60000; + +// ── Settle budget (spec P3.3, P3.4.3) ──────────────────────────────────── +/** What the probe sleeps for when a legacy `wait` carries no duration. */ +export const DEFAULT_SETTLE_BUDGET_MS = 30000; +/** Matches the server-side range check on `settle.budget_ms`. */ +export const MIN_SETTLE_BUDGET_MS = 100; +export const MAX_SETTLE_BUDGET_MS = 60000; + // ── Value field widths ─────────────────────────────────────────────────── export const VALUE_WIDTH_MAP: Record = { wait: "w-50!", @@ -131,3 +221,21 @@ export const VALUE_TOOLTIP_MAP: Record = { press: 'Press a keyboard key by its key name, e.g. "Enter", "Tab", "Escape", "ArrowDown".', assert: 'Assertion expression, e.g. "text=Hello" or "visible" to check element visibility.', }; + +// ── Recorder locator configuration ─────────────────────────────────────────── + +/** + * The test-id attribute the recorder selects on, unless a monitor overrides it. + * + * `data-test` because that is what OpenObserve's own frontend marks interactive + * elements with, and self-monitoring is the acceptance test for this feature. + * Playwright's own default is `data-testid`; sending nothing meant every + * recording fell back to that, so an O2 page only produced test-attribute + * candidates because upstream's generator carries a hardcoded fallback list + * that happens to include `data-test`. + * + * An application using anything outside that list — `data-qa`, `data-cy`, + * `data-pw`, `data-automation-id` — produced no test-attribute candidates at + * all, and every step silently degraded to role/text/css. + */ +export const DEFAULT_TEST_ID_ATTR = "data-test"; diff --git a/web/src/locales/languages/de-DE.json b/web/src/locales/languages/de-DE.json index 338bb70559..98b24a03ce 100644 --- a/web/src/locales/languages/de-DE.json +++ b/web/src/locales/languages/de-DE.json @@ -9777,7 +9777,39 @@ "sessionReplay": "Sitzungswiedergabe erfassen", "title": "RUM & Sitzungswiedergabe" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Aktion", "detailSelector": "Selektor", "downloadTrace": "{filename} herunterladen", @@ -9847,6 +9879,8 @@ "passRateByDevice": "Erfolgsrate nach Gerät", "passRateByLocation": "Erfolgsrate nach Standort", "durationByLocation": "Dauer nach Standort (Durchschnitt)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Wiederholungsrate", "statusTimeline": "Status-Zeitachse", "tabOverview": "Übersicht", diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index 69b5400bdf..70a7d1516c 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -9642,7 +9642,85 @@ "checks": "Checks" }, "journey": { + "locatorLabel": "How to find this element", + "locatorHelp": "Every way the recorder could find this element, most stable first. The list is what was recorded and cannot be edited — pin one to force the run to use it and nothing else.", + "locatorPin": "Always use this one", + "locatorUnpin": "Stop using only this one", + "locatorPinned": "Pinned", + "locatorPinnedNote": "Pinned: this step uses only this locator and will not fall back if it stops matching.", + "locatorAllPositionalWarning": "Every locator for this step finds the element by its position among similar ones, which breaks when the page reorders. Pin a locator you trust, or give the element a unique test attribute.", + "locatorFallbacksLead": "If it stops matching, these are tried in order:", + "locatorOverrideLabel": "Use a different locator", + "locatorOverrideApply": "Use this instead", + "locatorStartFromThis": "Start from this", + "locatorDerivedKindHelp": "Read from what you typed — the prefix decides how the runner resolves it.", + "locatorOverridePlaceholder": "#my-button or [data-test=\"sign-in\"]", + "locatorEmptyLabel": "How to find this element", + "locatorEmptyPlaceholder": "#my-button or [data-test=\"sign-in\"]", + "locatorKind": { + "test_attribute": "Test attribute", + "role": "Role", + "text": "Text", + "css": "CSS", + "xpath": "XPath" + }, + "assertionKindLabel": "Assertion", + "assertionExpectedLabel": "Expected", + "assertionAttributeLabel": "Attribute", + "assertionSuggestedName": "Verify the final page", + "assertionKind": { + "element_visible": "Element is visible", + "element_not_visible": "Element is not visible", + "element_text": "Element contains text", + "url_matches": "URL matches", + "page_title": "Page title is", + "element_attribute": "Attribute equals" + }, + "assertionExpectedPlaceholder": { + "element_visible": "", + "element_not_visible": "", + "element_text": "Welcome back", + "url_matches": "**/web/**", + "page_title": "Dashboard", + "element_attribute": "/web/" + }, + "settleLabel": "Waits for (recorded)", + "settleNavigation": "navigation to {pattern}", + "settleResponse": "response {method} {pattern}", + "settleRequiredLabel": "Required", + "settleBudgetLabel": "Wait for the page to settle (max, ms)", + "settleBudgetRangeWarning": "Outside the allowed {min}-{max} ms range. Saving will be rejected until this is corrected.", + "settleObserved": "settled in about {seconds}s when recorded", + "optionalLabel": "Optional — if this step fails, skip it and keep going", + "alwaysRunLabel": "Always run — run this step even after an earlier step failed", + "optionalHelp": "If this step fails, the run records it as Skipped and carries on. Use it for things that may not appear, like a cookie banner or a one-time popup. A skipped step never fails the run.", + "alwaysRunHelp": "If an earlier step fails, this step still runs during cleanup before the run ends. Use it for teardown, like signing out. Its result never changes the run's verdict, and it only applies to steps that come after the failed one.", + "upgradeTitle": "This journey can be upgraded", + "upgradeDescription": "It contains steps that cannot be replayed and hard waits that make runs slower and less reliable. Upgrading keeps the journey’s behaviour, converts each wait into a settle budget on the step before it, and lets the runner fall back through alternative locators.", + "upgradeChangeCount": "{count} changes", + "upgradeApply": "Upgrade journey", + "upgradePreview": "Preview changes", + "upgradeHide": "Hide changes", + "zeroAssertionTitle": "This journey does not verify anything", + "zeroAssertionDescription": "It checks that the steps can be performed, but not that the application is working. A journey with no assertion can click its way through a broken page and still pass. Add at least one — for example, that an element on the final page is visible.", + "zeroAssertionAdd": "Add an assertion", + "testIdMissingTitle": "No test attributes found in this recording", + "testIdMissingDescription": "The recorder looked for \"{attr}\" and no step matched one, so every step falls back to role, text or CSS selectors — the ones most likely to break when the page changes. If this application marks elements with a different attribute, set it before re-recording.", + "testIdMissingDismiss": "Dismiss", + "zeroAssertionDismiss": "Not now", "actionLabel": "Action", + "actionChangedNotice": "Changing the action rebuilds this step from the fields below. The recorded replay detail is not reused.", + "timeoutHelpInteraction": "Blank uses the runner default, {seconds} s. Maximum {max} s.", + "timeoutHelpNavAssert": "Blank uses the runner default, {seconds} s — also the maximum, so this field can only shorten it.", + "summaryWithTarget": "{action} {target}", + "summaryWaitingUpTo": "{sentence}, waiting up to {seconds} s.", + "groupAdvancedLabel": "Advanced", + "groupAdvancedCaption": "Page settling, step timeout, and what happens on failure", + "captionRecorded": "recorded", + "captionBudget": "budget {seconds}s", + "captionOptional": "Optional", + "captionAlwaysRun": "Always run", + "captionTimeout": "Timeout {seconds}s", "addStep": "Add Step", "addStepManually": "Add a step manually", "bulkDeleteStepsBody": "Delete {count} step(s)? This cannot be undone.", @@ -9668,6 +9746,8 @@ "noSteps": "No steps yet", "openExtensions": "Open chrome://extensions", "reRun": "Re-run", + "reRunToHere": "Re-run steps 1–{step}", + "fidelityLabel": "The preview could not reproduce", "record": "Record", "recordJourney": "Record journey", "recording": "Recording", @@ -9680,18 +9760,18 @@ "replaying": "Replaying…", "retry": "Retry", "selectedCount": "{count} selected", - "selectorLabel": "Selector", - "selectorTypeLabel": "Selector type", "showStackTrace": "Show", "showWindow": "Show window", "stepCount": "({count} steps)", "stepHeader": "Step", - "stepNameOptional": "Step name (optional)", + "stepNameLabel": "Step name", "stepNamePlaceholder": "Enter a descriptive name", + "stepNamePurposePlaceholder": "Sign in with valid credentials", "stepNumberAria": "Step {number} {state}", "steps": "Steps", "stop": "Stop", "stopAndReview": "Stop & Review", + "timeoutBelowDefaultWarning": "Below the {default} ms default for this step. A timeout shorter than the application's real response time is the most common cause of false failures.", "timeoutLabel": "Timeout (ms)", "timeoutPlaceholder": "30000", "valueFallback": "Value", @@ -9990,7 +10070,39 @@ "sessionReplay": "Capture session replay", "title": "RUM & Session Replay" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Action", "detailSelector": "Selector", "downloadTrace": "Download {filename}", @@ -10027,7 +10139,18 @@ "title": "Run Detail", "viewFullError": "View full error & stack trace", "viewFullErrorBtn": "View full error", - "detailUrl": "URL" + "detailUrl": "URL", + "locatorResolution": "Locator resolution", + "locatorNoneMatched": "No candidate matched — the element was not on the page.", + "locatorHealed": "The primary locator did not match; a fallback did. The markup has changed under this step.", + "settleSignals": "What the page did", + "settleStaleNote": "A recorded signal never arrived. The page did not do what it did when this was recorded, which is often the real cause of a later step timing out.", + "settleTiming": "Settle timing", + "settleTimingValue": "Settled in {now} — {recorded} when recorded.", + "settleSlower": "({times}x slower than recorded)", + "applicationEvidence": "What the application did", + "evidenceCounts": "{consoleErrors} console errors · {pageErrors} page errors · {failed} failed requests · {nonOk} non-2xx responses", + "evidenceTruncated": "Some events were dropped during this run because the capture limit was reached. The bundle names what went." }, "runRowExpansion": { "browserHeader": "Browser", @@ -10060,6 +10183,8 @@ "passRateByDevice": "Pass Rate by Device", "passRateByLocation": "Pass Rate by Location", "durationByLocation": "Duration by Location (Avg)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Retry Rate", "statusTimeline": "Status Timeline", "tabOverview": "Overview", @@ -10255,6 +10380,9 @@ "locationsRequired": "Select at least one location", "nameRequired": "Name is required", "selectorRequired": "Selector is required for \"{step}\"", + "stepNameRequired": "Give this step a name — it is what run results show", + "typeTextRequired": "Enter the text this step should type", + "expectedRequired": "Enter the value this assertion should expect", "urlInvalid": "Enter a valid URL starting with http:// or https://", "urlRequired": "Starting URL is required", "fixHighlightedFields": "Fix the highlighted fields before saving" diff --git a/web/src/locales/languages/es-ES.json b/web/src/locales/languages/es-ES.json index 16fe016c78..6091bbe51c 100644 --- a/web/src/locales/languages/es-ES.json +++ b/web/src/locales/languages/es-ES.json @@ -9777,7 +9777,39 @@ "sessionReplay": "Capturar repetición de sesión", "title": "RUM y repetición de sesión" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Acción", "detailSelector": "Selector", "downloadTrace": "Descargar {filename}", @@ -9847,6 +9879,8 @@ "passRateByDevice": "Tasa de aprobación por dispositivo", "passRateByLocation": "Tasa de aprobación por ubicación", "durationByLocation": "Duración por ubicación (promedio)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Tasa de reintento", "statusTimeline": "Línea de tiempo de estado", "tabOverview": "Resumen general", diff --git a/web/src/locales/languages/fr-FR.json b/web/src/locales/languages/fr-FR.json index ff6aaa3b1d..8c85383b52 100644 --- a/web/src/locales/languages/fr-FR.json +++ b/web/src/locales/languages/fr-FR.json @@ -9777,7 +9777,39 @@ "sessionReplay": "Capturer la relecture de session", "title": "RUM et relecture de session" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Action", "detailSelector": "Sélecteur", "downloadTrace": "Télécharger {filename}", @@ -9847,6 +9879,8 @@ "passRateByDevice": "Taux de réussite par appareil", "passRateByLocation": "Taux de réussite par emplacement", "durationByLocation": "Durée par emplacement (moy.)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Taux de nouvelle tentative", "statusTimeline": "Chronologie des statuts", "tabOverview": "Aperçu", diff --git a/web/src/locales/languages/it-IT.json b/web/src/locales/languages/it-IT.json index f53d209fa5..0be1c20ae8 100644 --- a/web/src/locales/languages/it-IT.json +++ b/web/src/locales/languages/it-IT.json @@ -9777,7 +9777,39 @@ "sessionReplay": "Cattura replay della sessione", "title": "RUM e Replay della Sessione" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Azione", "detailSelector": "Selettore", "downloadTrace": "Scarica {filename}", @@ -9847,6 +9879,8 @@ "passRateByDevice": "Tasso di superamento per dispositivo", "passRateByLocation": "Tasso di superamento per località", "durationByLocation": "Durata per posizione (Media)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Tasso di ritentativi", "statusTimeline": "Cronologia dello stato", "tabOverview": "Panoramica", diff --git a/web/src/locales/languages/ja-JP.json b/web/src/locales/languages/ja-JP.json index add862c2ed..7cbe15eaaa 100644 --- a/web/src/locales/languages/ja-JP.json +++ b/web/src/locales/languages/ja-JP.json @@ -9777,7 +9777,39 @@ "sessionReplay": "セッションリプレイをキャプチャ", "title": "RUM & セッションリプレイ" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "アクション", "detailSelector": "セレクタ", "downloadTrace": "{filename}をダウンロード", @@ -9847,6 +9879,8 @@ "passRateByDevice": "デバイス別合格率", "passRateByLocation": "ロケーション別合格率", "durationByLocation": "ロケーション別の期間(平均)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "リトライ率", "statusTimeline": "ステータスタイムライン", "tabOverview": "概要", diff --git a/web/src/locales/languages/ko-KR.json b/web/src/locales/languages/ko-KR.json index bc86e24923..a5326c17ea 100644 --- a/web/src/locales/languages/ko-KR.json +++ b/web/src/locales/languages/ko-KR.json @@ -9777,7 +9777,39 @@ "sessionReplay": "세션 리플레이 캡처", "title": "RUM 및 세션 리플레이" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "작업", "detailSelector": "선택기", "downloadTrace": "{filename} 다운로드", @@ -9847,6 +9879,8 @@ "passRateByDevice": "장치별 통과율", "passRateByLocation": "위치별 통과율", "durationByLocation": "위치별 기간(평균)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "재시도율", "statusTimeline": "상태 타임라인", "tabOverview": "개요", diff --git a/web/src/locales/languages/nl-NL.json b/web/src/locales/languages/nl-NL.json index 404a637548..49e293b590 100644 --- a/web/src/locales/languages/nl-NL.json +++ b/web/src/locales/languages/nl-NL.json @@ -9777,7 +9777,39 @@ "sessionReplay": "Sessie replay vastleggen", "title": "RUM en sessie replay" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Actie", "detailSelector": "Selector", "downloadTrace": "{filename} downloaden", @@ -9847,6 +9879,8 @@ "passRateByDevice": "Slagingspercentage per apparaat", "passRateByLocation": "Slagingspercentage per locatie", "durationByLocation": "Duur per locatie (gem.)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Herhalingspercentage", "statusTimeline": "Statustijdlijn", "tabOverview": "Overzicht", diff --git a/web/src/locales/languages/pl-PL.json b/web/src/locales/languages/pl-PL.json index ae51fcb4d3..ca17671ebb 100644 --- a/web/src/locales/languages/pl-PL.json +++ b/web/src/locales/languages/pl-PL.json @@ -9777,7 +9777,39 @@ "sessionReplay": "Przechwytuj odtwarzanie sesji", "title": "RUM i odtwarzanie sesji" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Akcja", "detailSelector": "Selektor", "downloadTrace": "Pobierz {filename}", @@ -9847,6 +9879,8 @@ "passRateByDevice": "Wskaźnik sukcesu według urządzenia", "passRateByLocation": "Wskaźnik sukcesu według lokalizacji", "durationByLocation": "Czas trwania według lokalizacji (śr.)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Wskaźnik ponownych prób", "statusTimeline": "Oś czasu statusu", "tabOverview": "Przegląd", diff --git a/web/src/locales/languages/pt-PT.json b/web/src/locales/languages/pt-PT.json index c703ced10d..160777c66e 100644 --- a/web/src/locales/languages/pt-PT.json +++ b/web/src/locales/languages/pt-PT.json @@ -9777,7 +9777,39 @@ "sessionReplay": "Capturar replay de sessão", "title": "RUM e Replay de Sessão" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Ação", "detailSelector": "Seletor", "downloadTrace": "Baixar {filename}", @@ -9847,6 +9879,8 @@ "passRateByDevice": "Taxa de aprovação por dispositivo", "passRateByLocation": "Taxa de aprovação por localização", "durationByLocation": "Duração por Localização (Média)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Taxa de repetição", "statusTimeline": "Linha do tempo de status", "tabOverview": "Visão geral", diff --git a/web/src/locales/languages/ru-RU.json b/web/src/locales/languages/ru-RU.json index 51a7f5bd14..f52290b85b 100644 --- a/web/src/locales/languages/ru-RU.json +++ b/web/src/locales/languages/ru-RU.json @@ -9777,7 +9777,39 @@ "sessionReplay": "Захватывать воспроизведение сессии", "title": "RUM и воспроизведение сессии" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Действие", "detailSelector": "Селектор", "downloadTrace": "Скачать {filename}", @@ -9847,6 +9879,8 @@ "passRateByDevice": "Процент успеха по устройству", "passRateByLocation": "Процент успеха по локации", "durationByLocation": "Продолжительность по местоположению (среднее)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Процент повторных попыток", "statusTimeline": "Временная шкала статусов", "tabOverview": "Обзор", diff --git a/web/src/locales/languages/tr-TR.json b/web/src/locales/languages/tr-TR.json index fdb55f83cc..11959a4424 100644 --- a/web/src/locales/languages/tr-TR.json +++ b/web/src/locales/languages/tr-TR.json @@ -9777,7 +9777,39 @@ "sessionReplay": "Oturum tekrarını yakala", "title": "RUM ve Oturum Tekrarı" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Eylem", "detailSelector": "Seçici", "downloadTrace": "{filename} indir", @@ -9847,6 +9879,8 @@ "passRateByDevice": "Cihaza Göre Geçme Oranı", "passRateByLocation": "Konuma Göre Geçme Oranı", "durationByLocation": "Konuma Göre Süre (Ort)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Tekrar Deneme Oranı", "statusTimeline": "Durum Zaman Çizelgesi", "tabOverview": "Genel Bakış", diff --git a/web/src/locales/languages/vi-VN.json b/web/src/locales/languages/vi-VN.json index 0ba151ac76..dbb5f3efbe 100644 --- a/web/src/locales/languages/vi-VN.json +++ b/web/src/locales/languages/vi-VN.json @@ -9777,7 +9777,39 @@ "sessionReplay": "Ghi lại phát lại phiên", "title": "RUM & Phát lại phiên" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "Hành động", "detailSelector": "Bộ chọn", "downloadTrace": "Tải xuống {filename}", @@ -9847,6 +9879,8 @@ "passRateByDevice": "Tỷ lệ đạt theo thiết bị", "passRateByLocation": "Tỷ lệ đạt theo vị trí", "durationByLocation": "Thời lượng theo vị trí (Trung bình)", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "Tỷ lệ thử lại", "statusTimeline": "Dòng thời gian trạng thái", "tabOverview": "Tổng quan", diff --git a/web/src/locales/languages/zh-CN.json b/web/src/locales/languages/zh-CN.json index a7d83fd607..f3113fff31 100644 --- a/web/src/locales/languages/zh-CN.json +++ b/web/src/locales/languages/zh-CN.json @@ -9777,7 +9777,39 @@ "sessionReplay": "捕获会话回放", "title": "RUM与会话回放" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "操作", "detailSelector": "选择器", "downloadTrace": "下载 {filename}", @@ -9847,6 +9879,8 @@ "passRateByDevice": "按设备的通过率", "passRateByLocation": "按位置的通过率", "durationByLocation": "按位置的平均持续时间", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "重试率", "statusTimeline": "状态时间线", "tabOverview": "概览", diff --git a/web/src/locales/languages/zh-TW.json b/web/src/locales/languages/zh-TW.json index 1536a8c144..409f0e95a3 100644 --- a/web/src/locales/languages/zh-TW.json +++ b/web/src/locales/languages/zh-TW.json @@ -9772,7 +9772,39 @@ "sessionReplay": "擷取工作階段重播", "title": "RUM 與工作階段重播" }, + "evidence": { + "groupPageErrors": "Page errors", + "groupFailedReq": "Failed requests", + "groupConsole": "Console", + "groupNetwork": "Network", + "title": "Evidence · {count} events", + "filterAll": "All", + "filterConsole": "Console errors", + "filterPageErrors": "Page errors", + "filterNon2xx": "Non-2xx", + "filterFailedReq": "Failed requests", + "firstPartyOnly": "First-party only", + "failedHere": "failed here", + "noEventsInStep": "No events in this step's window.", + "noEvents": "No console, network or page events were captured.", + "unattributed": "Not attributed to a step", + "truncated": "Capture cap reached (2000 events / 256 KB). Later events are not in this bundle — download to see what was kept.", + "captureOff": "Evidence capture is off for this check.", + "failuresOnly": "Evidence is kept for failed runs only.", + "none": "No evidence bundle was uploaded for this attempt.", + "loadFailed": "Could not load the evidence bundle: {error}", + "retry": "Retry", + "stack": "stack" + }, "runDetail": { + "evidenceSection": "Evidence", + "attempts": "Attempts", + "attemptsLabel": "{count} attempts", + "attemptN": "Attempt {n}", + "attemptDecided": "decided", + "attemptReducedDetail": "Superseded attempt: step timeline and screenshots only. Full failure forensics are retained for the attempt that decided the run.", + "initTime": "Init", + "queueDelay": "Queue Delay", "detailAction": "動作", "detailSelector": "選取器", "downloadTrace": "下載 {filename}", @@ -9842,6 +9874,8 @@ "passRateByDevice": "按裝置的通過率", "passRateByLocation": "按位置的通過率", "durationByLocation": "按位置的平均持續時間", + "flakyRate": "Flaky Rate", + "stepsWindowTruncated": "Showing the newest {count} executions ({from} \u2013 {to}); the selected range holds more.", "retryRate": "重試率", "statusTimeline": "狀態時間軸", "tabOverview": "總覽", diff --git a/web/src/types/synthetics.ts b/web/src/types/synthetics.ts index 6e2a272f07..fe77755e03 100644 --- a/web/src/types/synthetics.ts +++ b/web/src/types/synthetics.ts @@ -15,6 +15,16 @@ export interface StructuredError { } /** Per-step outcome pushed by the extension via stepReplayResult. */ +/** + * What the player reported about one replayed step. + * + * `fidelity` carries the X-8.2 divergence notes: the preview cannot reproduce every + * probe behaviour (ordered candidate fallback, navigation and response settle, some + * assertion kinds, an author-set timeout below 60 s, uploads, retired actions), and + * the requirement is that it SAYS so per step rather than diverging in silence. The + * extension emits these; the web layer used to drop them here, so every one of those + * divergences was invisible and a skipped step could read as a pass. + */ export interface StepReplayResult { stepId: string; stepName: string; @@ -22,6 +32,7 @@ export interface StepReplayResult { durationMs: number; error?: string; structuredError?: StructuredError; + fidelity?: { level: string; notes: string[] }; } export type StepAction = @@ -30,20 +41,122 @@ export type StepAction = | "type" | "select" | "press" + // `check` / `uncheck` / `upload` are version-2 additions. The recorder used to + // collapse a checkbox interaction to a plain click, which made the replayed + // journey depend on the box's starting state — a page that renders it + // pre-ticked silently inverted the journey (spec X-9.3). + | "check" + | "uncheck" + | "upload" | "hover" | "scroll" | "wait" | "assert" | "screenshot"; +// ── Version-2 locator bundle ──────────────────────────────────────────────── +// A v1 step identifies its element with a single selector, so any cosmetic +// markup change breaks the monitor. A v2 step carries every way the recorder +// could find the element, ordered most-stable-first, and the runner falls back +// through them. +// +// Ordering matters and is not cosmetic: candidates only agree while the markup +// is unchanged. Once it changes — the case fallback exists for — a lower-ranked +// candidate may match a *different* element, so rank is what breaks ties. + +/** Ordered most stable to least. `css`/`xpath` are structural and brittle. */ +export type LocatorKind = "test_attribute" | "role" | "text" | "css" | "xpath"; + +export interface LocatorCandidate { + kind: LocatorKind; + value: string; +} + +export interface StepLocator { + /** + * Machine-derived evidence from the recording session. Read-only in the UI: + * author intent is expressed only by pinning, which keeps the stored list + * byte-comparable for the self-healing precondition. + */ + candidates: LocatorCandidate[]; + /** Author-pinned. When set, used exclusively — never falls back. */ + user_override?: LocatorCandidate | null; +} + +// ── Version-2 settle block ────────────────────────────────────────────────── +// What the page demonstrably did after a step, observed while recording and +// waited for again at run time. This is what replaces hard sleeps: a sleep is +// simultaneously too short when the application is slow and pure waste when it +// is fast, whereas a settle signal is neither. +// +// Every signal is advisory unless an author marks it required. A recorded signal +// is evidence from one session, not a contract — an endpoint that gets renamed +// must annotate the step, not turn a healthy journey red. + +export interface SettleNavigation { + /** Glob over the URL with the query string stripped, e.g. `**\/web/**`. */ + url_pattern: string; +} + +export interface SettleResponse { + url_pattern: string; + method?: string; + /** + * Author-set only. The recorder always emits `false`: deciding that a run is + * meaningless without a given call is a judgement about the application, not + * something a recording can observe. + */ + required?: boolean; +} + +export interface StepSettle { + navigation?: SettleNavigation; + responses?: SettleResponse[]; + /** How long settling took while recording. Reporting only — never a timeout. */ + observed_duration_ms?: number; + /** How long this step may spend settling. Absent means the runner's 30s. */ + budget_ms?: number; +} + +// ── Version-2 assertions ──────────────────────────────────────────────────── +// A journey that only clicks can click its way through a broken application and +// still pass. An assertion is what turns a sequence of interactions into a +// statement about an outcome. + +export type AssertionKind = + | "element_visible" + | "element_not_visible" + | "element_text" + | "url_matches" + | "page_title" + | "element_attribute"; + +export interface StepAssertion { + kind: AssertionKind; + /** Required for every kind except the two visibility ones. */ + expected?: string; + /** Required for `element_attribute`. */ + attribute?: string; +} + export interface BrowserStep { id: string; action: StepAction; name?: string; selector?: string; selectorType?: SelectorType; + /** Version-2 locator bundle. Absent on v1 steps, which use `selector`. */ + locator?: StepLocator; + /** Version-2 settle block: what to wait for after this step's action. */ + settle?: StepSettle; + /** Version-2 typed assertion. Required on `assert`, forbidden elsewhere. */ + assertion?: StepAssertion; + /** Failure skips the step and the run continues (cookie banners, popups). */ + optional?: boolean; + /** Runs even after an earlier step failed (logout, cleanup). */ + alwaysRun?: boolean; value?: string; - timeout?: number; // ms, default 30000 + timeout?: number; // ms; undefined = runner's per-category default code: string; // Original, untouched extension step (see WireStep). Preserved for replay, // which sends the rich step back to the extension verbatim. Absent on @@ -63,9 +176,18 @@ export type RecorderMode = "recording" | "inspecting" | "asserting" | "playing"; */ export interface WireStep { id: string; - action: string; // navigate | click | type | press | select | setInputFiles | waitFor | assert | screenshot + action: string; // navigate | click | type | press | select | check | uncheck | setInputFiles | waitFor | assert | screenshot selector?: string; selector_type?: "css" | "xpath" | "text" | "role" | "data-test"; + /** + * Version-2 evidence captured by the extension. Present on recorded steps + * only; a hand-added step has none until it is re-recorded. + */ + locator?: StepLocator; + settle?: StepSettle; + assertion?: StepAssertion; + optional?: boolean; + always_run?: boolean; name?: string; timeout_ms?: number; url?: string; @@ -164,6 +286,12 @@ export type RecorderPushPayload = duration_ms: number; error?: string; structuredError?: StructuredError; + /** X-8.2 divergence notes, as the extension emits them. Declared here as + * well as on `StepReplayResult` because this is the INBOUND shape the + * composable reads from; without it, reading `payload.fidelity` does not + * type-check, and dropping the read instead would make every divergence + * silent again — a skipped step reading as a pass. */ + fidelity?: { level: string; notes: string[] }; } | { method: "stepReplayStarted"; stepId: string; stepName?: string }; diff --git a/web/src/utils/synthetics/buildPayload.ts b/web/src/utils/synthetics/buildPayload.ts index b83630c99a..1927d50cb5 100644 --- a/web/src/utils/synthetics/buildPayload.ts +++ b/web/src/utils/synthetics/buildPayload.ts @@ -13,6 +13,7 @@ import type { } from "@/types/synthetics"; import { convertDateToTimestamp } from "@/utils/timezone"; import { journeyToWireSteps, mapWireSteps } from "./mapRecordedStep"; +import { buildV2Steps, isV2Journey } from "./buildV2Steps"; import { useLocalTimezone } from "../storage"; // ── Outbound: BrowserCheck → API payload ───────────────────────────────────── @@ -124,7 +125,12 @@ export function buildCreateBrowserTestPayload(check: BrowserCheck): Record): Browse browserDevices: config?.browser_devices, + // No `preserveWire`: a stored version-2 step is poorer than what + // buildWireFromStep rebuilds from the UI fields, so keeping it would shadow + // the correct reconstruction — which is how a reloaded `select` came to + // replay as "select nothing". See MapWireStepOptions. journey: mapWireSteps(config?.steps ?? []), ...(variables?.length && { diff --git a/web/src/utils/synthetics/buildV2Steps.spec.ts b/web/src/utils/synthetics/buildV2Steps.spec.ts new file mode 100644 index 0000000000..0da40a4545 --- /dev/null +++ b/web/src/utils/synthetics/buildV2Steps.spec.ts @@ -0,0 +1,176 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import type { BrowserStep } from "@/types/synthetics"; +import { buildV2Step, buildV2Steps, isV2Journey } from "./buildV2Steps"; + +function step(overrides: Partial = {}): BrowserStep { + return { + id: "s2", + action: "click", + name: "Sign In", + selector: '[data-test="login-sign-in"]', + selectorType: "TestID", + locator: { + candidates: [{ kind: "test_attribute", value: '[data-test="login-sign-in"]' }], + }, + code: "", + ...overrides, + }; +} + +const nav = (): BrowserStep => ({ + id: "s1", + action: "navigate", + name: "Open", + value: "https://example.com", + code: "", +}); + +describe("isV2Journey", () => { + it("accepts a journey whose every element step carries a bundle", () => { + expect(isV2Journey([nav(), step()])).toBe(true); + }); + + // All-or-nothing: `steps_version` describes the whole array. A half-lifted + // journey stored as v2 would fail validation on the bundle-less steps, and + // stored as v1 would silently discard the bundles that were captured. + it("rejects a journey where one step has no bundle", () => { + expect(isV2Journey([nav(), step(), step({ id: "s3", locator: undefined })])).toBe(false); + }); + + it("rejects a journey containing a retired action", () => { + expect(isV2Journey([nav(), step(), step({ id: "s3", action: "wait" })])).toBe(false); + }); + + it("accepts a page-level assertion with no element at all", () => { + const urlAssert = step({ + id: "s3", + action: "assert", + locator: undefined, + selector: undefined, + assertion: { kind: "url_matches", expected: "**/web/**" }, + }); + expect(isV2Journey([nav(), urlAssert])).toBe(true); + }); + + it("rejects an empty journey", () => { + expect(isV2Journey([])).toBe(false); + }); +}); + +describe("buildV2Step", () => { + // The server validates v2 steps with deny_unknown_fields, so a stray `code` + // or `selectorType` is a 400 rather than a harmless extra. + it("emits only fields the schema knows", () => { + const wire = buildV2Step(step()); + expect(Object.keys(wire).sort()).toEqual(["action", "id", "locator", "name"]); + }); + + it("translates the UI action names onto the v2 vocabulary", () => { + expect(buildV2Step(step({ action: "type", value: "omkar" })).action).toBe("fill"); + expect(buildV2Step(step({ action: "check" })).action).toBe("check"); + expect(buildV2Step(step({ action: "upload" })).action).toBe("upload"); + expect(buildV2Step(nav()).action).toBe("navigate"); + }); + + it("routes the single UI value into the field each action expects", () => { + expect(buildV2Step(nav()).url).toBe("https://example.com"); + expect(buildV2Step(step({ action: "press", value: "Enter" })).key).toBe("Enter"); + expect(buildV2Step(step({ action: "type", value: "omkar" })).value).toBe("omkar"); + }); + + it("carries a pin, which the runner uses exclusively", () => { + const wire = buildV2Step( + step({ + locator: { + candidates: [{ kind: "test_attribute", value: "#a" }], + user_override: { kind: "css", value: "#pinned" }, + }, + }), + ); + expect(wire.locator?.user_override).toEqual({ kind: "css", value: "#pinned" }); + }); + + it("carries the settle block and defaults a signal to advisory", () => { + const wire = buildV2Step( + step({ + settle: { + navigation: { url_pattern: "**/web/**" }, + responses: [{ url_pattern: "**/auth/login", method: "POST" }], + observed_duration_ms: 1800, + budget_ms: 30000, + }, + }), + ); + expect(wire.settle?.navigation).toEqual({ url_pattern: "**/web/**" }); + expect(wire.settle?.responses).toEqual([ + { url_pattern: "**/auth/login", method: "POST", required: false }, + ]); + expect(wire.settle?.observed_duration_ms).toBe(1800); + expect(wire.settle?.budget_ms).toBe(30000); + }); + + // Validation requires an assertion on every assert step. Defaulting here means + // an author who never opened the assertion editor gets the step's original + // meaning rather than a 400. + it("gives an assert step with no typed assertion its original meaning", () => { + expect(buildV2Step(step({ action: "assert" })).assertion).toEqual({ kind: "element_visible" }); + }); + + it("carries a typed assertion verbatim", () => { + const wire = buildV2Step( + step({ + action: "assert", + assertion: { kind: "element_attribute", attribute: "href", expected: "/web/" }, + }), + ); + expect(wire.assertion).toEqual({ + kind: "element_attribute", + attribute: "href", + expected: "/web/", + }); + }); + + it("sends flow control only when it is set", () => { + expect(buildV2Step(step()).optional).toBeUndefined(); + expect(buildV2Step(step()).always_run).toBeUndefined(); + expect(buildV2Step(step({ optional: true })).optional).toBe(true); + expect(buildV2Step(step({ alwaysRun: true })).always_run).toBe(true); + }); + + // Absence means "use the runner's per-category default"; sending a number the + // author never chose is how the 10s stamp caused the original failures. + it("sends a timeout only when the author set one", () => { + expect(buildV2Step(step()).timeout_ms).toBeUndefined(); + expect(buildV2Step(step({ timeout: 45000 })).timeout_ms).toBe(45000); + }); + + it("refuses to build a step that has no version-2 equivalent", () => { + expect(() => buildV2Step(step({ action: "hover" }))).toThrow(/no version-2 equivalent/); + }); + + it("maps a whole journey in order", () => { + expect(buildV2Steps([nav(), step()]).map((s) => s.id)).toEqual(["s1", "s2"]); + }); +}); + +// SE-18 regression. `steps_version` describes the whole array, so isV2Journey +// uses .every(): one hand-added step without a locator flipped an entire recorded +// journey to v1, sending every other step's bundle, settle and assertion down the +// untyped path. A seeded bundle plus an author-supplied pin keeps it v2. +describe("SE-18: a completed manual step does not downgrade the journey", () => { + it("stays version 2 when a hand-added step names its element via locator", () => { + const journey: BrowserStep[] = [ + { id: "s1", action: "navigate", name: "Open app", value: "https://example.com", code: "" }, + { + id: "s2", + action: "click", + name: "Sign in", + code: "", + locator: { candidates: [], user_override: { kind: "css", value: "#sign-in" } }, + }, + ]; + expect(isV2Journey(journey)).toBe(true); + }); +}); diff --git a/web/src/utils/synthetics/buildV2Steps.ts b/web/src/utils/synthetics/buildV2Steps.ts new file mode 100644 index 0000000000..cbd0a9c927 --- /dev/null +++ b/web/src/utils/synthetics/buildV2Steps.ts @@ -0,0 +1,168 @@ +// Copyright 2026 OpenObserve Inc. + +import type { BrowserStep, StepAction } from "@/types/synthetics"; +import { RETIRED_ACTIONS } from "@/constants/synthetics"; + +/** + * The version-2 step as it goes on the wire. + * + * Deliberately a separate type from {@link BrowserStep}: the server validates v2 + * steps with `deny_unknown_fields`, so a stray `code`, `selectorType` or + * `startTime` is a 400, not a harmless extra. Building the payload from an + * explicit shape rather than by spreading the editor's model is what keeps that + * from happening by accident. + */ +export interface V2WireStep { + id: string; + action: string; + name?: string; + url?: string; + locator?: { + candidates: Array<{ kind: string; value: string }>; + user_override?: { kind: string; value: string } | null; + }; + value?: string; + key?: string; + files?: string[]; + settle?: { + navigation?: { url_pattern: string }; + responses?: Array<{ url_pattern: string; method?: string; required?: boolean }>; + observed_duration_ms?: number; + budget_ms?: number; + }; + assertion?: { kind: string; expected?: string; attribute?: string }; + optional?: boolean; + always_run?: boolean; + timeout_ms?: number; +} + +/** UI action name → the v2 vocabulary the server accepts. */ +const ACTION_TO_V2: Partial> = { + navigate: "navigate", + click: "click", + type: "fill", + select: "select", + press: "press", + check: "check", + uncheck: "uncheck", + upload: "upload", + assert: "assert", +}; + +/** Actions that carry no element and therefore need no locator. */ +const PAGE_LEVEL_ASSERTIONS = new Set(["url_matches", "page_title"]); + +/** + * Whether a journey can be stored as version 2. + * + * All-or-nothing on purpose. `steps_version` describes the whole `steps` array, + * so a journey where only some steps carry a locator bundle has no honest + * version — storing it as v2 would fail validation on the bundle-less steps, and + * storing it as v1 would silently discard the bundles that were captured. A + * journey qualifies once every step could be executed by the v2 runner. + */ +export function isV2Journey(steps: BrowserStep[]): boolean { + if (steps.length === 0) return false; + return steps.every((step) => { + if ((RETIRED_ACTIONS as readonly string[]).includes(step.action)) return false; + if (!ACTION_TO_V2[step.action]) return false; + if (step.action === "navigate") return true; + if (step.action === "assert" && PAGE_LEVEL_ASSERTIONS.has(step.assertion?.kind ?? "")) + return true; + return !!(step.locator?.candidates?.length || step.locator?.user_override); + }); +} + +/** The value field a v2 step uses for this action. The UI keeps only one. */ +function v2Value(step: BrowserStep): Pick { + switch (step.action) { + case "navigate": + return { url: step.value }; + case "press": + return { key: step.value }; + case "type": + case "select": + return { value: step.value }; + case "upload": + // The editor keeps a single value, so a manually-added upload carries one + // path. Dropping it would save a step that uploads nothing. + return step.value ? { files: [step.value] } : {}; + default: + return {}; + } +} + +/** + * Build the stored v2 step for one editor step. + * + * Every field is copied explicitly rather than spread. That is the point: it is + * the only way to be sure the payload contains nothing the schema will refuse, + * and it makes adding a field a deliberate act in both repositories at once. + */ +export function buildV2Step(step: BrowserStep): V2WireStep { + const action = ACTION_TO_V2[step.action]; + if (!action) { + throw new Error( + `step "${step.id}": "${step.action}" has no version-2 equivalent. ` + + `Check isV2Journey before building a version-2 payload.`, + ); + } + + const wire: V2WireStep = { id: step.id, action }; + + if (step.name) wire.name = step.name; + Object.assign(wire, v2Value(step)); + + if (step.locator?.candidates?.length || step.locator?.user_override) { + wire.locator = { + candidates: (step.locator.candidates ?? []).map((c) => ({ kind: c.kind, value: c.value })), + ...(step.locator.user_override && { + user_override: { + kind: step.locator.user_override.kind, + value: step.locator.user_override.value, + }, + }), + }; + } + + if (step.settle) { + const settle: NonNullable = {}; + if (step.settle.navigation) + settle.navigation = { url_pattern: step.settle.navigation.url_pattern }; + if (step.settle.responses?.length) { + settle.responses = step.settle.responses.map((r) => ({ + url_pattern: r.url_pattern, + ...(r.method && { method: r.method }), + required: r.required ?? false, + })); + } + if (step.settle.observed_duration_ms !== undefined) { + settle.observed_duration_ms = step.settle.observed_duration_ms; + } + if (step.settle.budget_ms !== undefined) settle.budget_ms = step.settle.budget_ms; + if (Object.keys(settle).length) wire.settle = settle; + } + + if (step.action === "assert") { + // Validation requires one, and defaulting here rather than at save time + // means an author who never opened the assertion editor still gets the + // step's original meaning — "the element is on screen" — instead of a 400. + const assertion = step.assertion ?? { kind: "element_visible" as const }; + wire.assertion = { + kind: assertion.kind, + ...(assertion.expected !== undefined && { expected: assertion.expected }), + ...(assertion.attribute !== undefined && { attribute: assertion.attribute }), + }; + } + + if (step.optional) wire.optional = true; + if (step.alwaysRun) wire.always_run = true; + // Only an explicit author choice travels; absence means the runner's default. + if (step.timeout !== undefined) wire.timeout_ms = step.timeout; + + return wire; +} + +export function buildV2Steps(steps: BrowserStep[]): V2WireStep[] { + return steps.map(buildV2Step); +} diff --git a/web/src/utils/synthetics/deriveLocatorKind.spec.ts b/web/src/utils/synthetics/deriveLocatorKind.spec.ts new file mode 100644 index 0000000000..57fc01e7b8 --- /dev/null +++ b/web/src/utils/synthetics/deriveLocatorKind.spec.ts @@ -0,0 +1,53 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import { deriveLocatorKind } from "./deriveLocatorKind"; + +describe("deriveLocatorKind", () => { + it("reads the engine prefix on the first segment", () => { + expect(deriveLocatorKind('internal:testid=[data-qa="submit"]')).toBe("test_attribute"); + expect(deriveLocatorKind("role=button")).toBe("role"); + expect(deriveLocatorKind('role=button[name="Sign In"]')).toBe("role"); + expect(deriveLocatorKind('internal:role=button[name="Save draft"i]')).toBe("role"); + expect(deriveLocatorKind("text=Sign in")).toBe("text"); + expect(deriveLocatorKind('internal:text="Sign in"i')).toBe("text"); + expect(deriveLocatorKind("xpath=//div[@id='a']")).toBe("xpath"); + expect(deriveLocatorKind("//div[@id='a']")).toBe("xpath"); + expect(deriveLocatorKind("(//div)[2]")).toBe("xpath"); + }); + + it("falls back to css for anything else", () => { + expect(deriveLocatorKind("#pinned")).toBe("css"); + expect(deriveLocatorKind(".btn-primary")).toBe("css"); + expect(deriveLocatorKind("button")).toBe("css"); + expect(deriveLocatorKind("")).toBe("css"); + }); + + // A bare attribute selector IS css — `[data-qa="x"]` is valid CSS and resolves as + // such. The recorder labels its own output `test_attribute` because it knows the + // provenance; a string an author typed carries none, and inferring one would need + // the monitor's testIdAttr, which is mutable config the editor cannot see (D3). + it("treats a bare attribute selector as css, not test_attribute", () => { + expect(deriveLocatorKind('[data-test="sign-in"]')).toBe("css"); + expect(deriveLocatorKind('[data-qa="submit"]')).toBe("css"); + }); + + // The rule that earns first-segment matching. A substring search for `text=` + // would return "text" here, where the recorder stored "css". + it("matches the first >> segment only, never a substring", () => { + expect(deriveLocatorKind("div >> internal:has-text=/^Acme$/ >> nth=0")).toBe("css"); + expect(deriveLocatorKind('[data-test="row"] >> nth=1')).toBe("css"); + expect(deriveLocatorKind('role=row >> internal:text="x"')).toBe("role"); + }); + + it("tolerates leading whitespace", () => { + expect(deriveLocatorKind(" role=button")).toBe("role"); + expect(deriveLocatorKind(" //div")).toBe("xpath"); + }); + + it("takes no configuration — the same value always derives the same kind", () => { + // Guards D3's central property: derivation is a pure function of the value, so + // it cannot change when a monitor's testIdAttr is edited. + expect(deriveLocatorKind.length).toBe(1); + }); +}); diff --git a/web/src/utils/synthetics/deriveLocatorKind.ts b/web/src/utils/synthetics/deriveLocatorKind.ts new file mode 100644 index 0000000000..8c99da9210 --- /dev/null +++ b/web/src/utils/synthetics/deriveLocatorKind.ts @@ -0,0 +1,43 @@ +// Copyright 2026 OpenObserve Inc. + +import type { LocatorKind } from "@/types/synthetics"; + +/** + * Classify an author-written locator value (spec decision D3). + * + * A version-2 locator's `kind` labels the value; it does not parse it. Both + * consumers resolve a locator by handing `value` straight to `page.locator()` — + * the probe at `v2runner.ts:140`, the extension via `effectiveSelector` — and read + * `kind` only when reporting which candidates were tried. So the kind must agree + * with what the string actually is, and a picker that merely *sets* `kind` would + * produce silently wrong locators: choosing "Role" and typing `button` stores + * `{ kind: "role", value: "button" }`, and `button` resolves as a CSS tag selector + * matching every button on the page. + * + * Hence: derive, never pick. The engine is already encoded in the value's prefix. + * + * **Pure function of the value alone — no configuration.** A rule for bare + * attribute selectors (`[data-qa="x"]` → `test_attribute`) was specified and + * removed: it would need the monitor's `testIdAttr`, which the editor cannot see + * (it reaches only `TestIdMisconfiguredNotice`), and which is mutable config — so + * the same string would classify differently after someone edited it. A bare + * attribute selector is also genuinely CSS. The self-describing + * `internal:testid=` form carries the attribute inside the value and is caught by + * rule 1, which is what that form exists for. + * + * Matching is anchored to the start of the **first `>>` segment**. Chained + * Playwright selectors join with `>>` and the engine of the whole expression is set + * by its first segment; a substring search would misread + * `div >> internal:has-text=…` as `text` when the recorder stored `css`. + */ +export function deriveLocatorKind(value: string): LocatorKind { + const segment = (value.split(">>")[0] ?? "").trim(); + + if (segment.startsWith("internal:testid=")) return "test_attribute"; + if (segment.startsWith("internal:role=") || segment.startsWith("role=")) return "role"; + if (segment.startsWith("internal:text=") || segment.startsWith("text=")) return "text"; + if (segment.startsWith("xpath=") || segment.startsWith("//") || segment.startsWith("(//")) + return "xpath"; + + return "css"; +} diff --git a/web/src/utils/synthetics/liftJourney.spec.ts b/web/src/utils/synthetics/liftJourney.spec.ts new file mode 100644 index 0000000000..78282da75f --- /dev/null +++ b/web/src/utils/synthetics/liftJourney.spec.ts @@ -0,0 +1,251 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import type { BrowserStep } from "@/types/synthetics"; +import { liftJourney, needsLift } from "./liftJourney"; + +function step(overrides: Partial = {}): BrowserStep { + return { + id: "s1", + action: "click", + name: "Sign In", + selector: '[data-test="login-sign-in"]', + selectorType: "TestID", + code: "", + ...overrides, + }; +} + +describe("liftJourney", () => { + // ── Locator bundle (spec P2.6.2) ───────────────────────────────────────── + + it("turns a single selector into a one-candidate bundle", () => { + const { steps } = liftJourney([step()]); + expect(steps[0].locator).toEqual({ + candidates: [{ kind: "test_attribute", value: '[data-test="login-sign-in"]' }], + user_override: null, + }); + }); + + it("maps every selector type onto its locator kind", () => { + const cases = [ + ["TestID", "test_attribute"], + ["Role", "role"], + ["Text", "text"], + ["CSS", "css"], + ["XPath", "xpath"], + ] as const; + for (const [selectorType, kind] of cases) { + const { steps } = liftJourney([step({ selectorType })]); + expect(steps[0].locator?.candidates[0].kind, selectorType).toBe(kind); + } + }); + + // A hand-authored step has no recorded selector type. CSS is the only safe + // assumption — guessing `test_attribute` would put a brittle selector at the + // top of the stability ordering. + it("falls back to css when the selector type is unknown", () => { + const { steps } = liftJourney([step({ selectorType: undefined, selector: "#login > button" })]); + expect(steps[0].locator?.candidates[0].kind).toBe("css"); + }); + + it("leaves an existing locator bundle alone", () => { + const existing = { + candidates: [{ kind: "role" as const, value: "role=button" }], + user_override: null, + }; + const { steps } = liftJourney([step({ locator: existing })]); + expect(steps[0].locator).toBe(existing); + }); + + // ── Timeout stamp ──────────────────────────────────────────────────────── + + it("clears the recorder's 10s stamp so the runner default applies", () => { + const { steps } = liftJourney([step({ timeout: 10000 })]); + expect(steps[0].timeout).toBeUndefined(); + }); + + // Any other value was a deliberate choice and must survive the lift. + it("preserves an author-set timeout", () => { + const { steps } = liftJourney([step({ timeout: 45000 })]); + expect(steps[0].timeout).toBe(45000); + }); + + // ── Retired actions (spec X-9) ─────────────────────────────────────────── + + it("drops retired actions, which cannot exist in version 2", () => { + const journey = [ + step({ id: "s1", action: "navigate", value: "https://example.com", selector: undefined }), + step({ id: "s2", action: "wait", selector: undefined }), + step({ id: "s3", action: "scroll", selector: undefined }), + step({ id: "s4", action: "screenshot", selector: undefined }), + step({ id: "s5", action: "hover" }), + step({ id: "s6", action: "click" }), + ]; + const { steps } = liftJourney(journey); + expect(steps.map((s) => s.id)).toEqual(["s1", "s6"]); + }); + + // Dropping a step is a real behaviour change. It must be surfaced so an + // author sees it in the preview instead of discovering it from a diff. + it("reports every drop with a reason", () => { + const { changes } = liftJourney([step({ id: "s2", action: "wait", selector: undefined })]); + const drop = changes.find((c) => c.kind === "step_dropped"); + expect(drop).toBeDefined(); + expect(drop!.stepId).toBe("s2"); + expect(drop!.detail).toMatch(/sleep/i); + }); + + // ── Sleeps become settle budgets (spec P3.4.3 / T3-6) ──────────────────── + + it("converts a sleep into a settle budget on the preceding step", () => { + const { steps, changes } = liftJourney([ + step({ id: "s1", action: "click" }), + step({ id: "s2", action: "wait", timeout: 30000, selector: undefined }), + step({ id: "s3", action: "assert" }), + ]); + expect(steps.map((s) => s.id)).toEqual(["s1", "s3"]); + expect(steps[0].settle?.budget_ms).toBe(30000); + const converted = changes.find((c) => c.kind === "sleep_converted"); + expect(converted?.stepId).toBe("s2"); + expect(converted?.detail).toMatch(/settle budget/i); + }); + + // The duration is the one piece of a sleep that carries author intent — "this + // step needs longer than usual". Dropping it would silently tighten the run. + it("keeps the author's duration, reading it from value when timeout is unset", () => { + const { steps } = liftJourney([ + step({ id: "s1", action: "click" }), + step({ id: "s2", action: "wait", value: "5000", selector: undefined }), + ]); + expect(steps[0].settle?.budget_ms).toBe(5000); + }); + + it("clamps a converted budget into the range the server accepts", () => { + const { steps } = liftJourney([ + step({ id: "s1", action: "click" }), + step({ id: "s2", action: "wait", timeout: 120000, selector: undefined }), + ]); + expect(steps[0].settle?.budget_ms).toBe(60000); + }); + + // Nothing to attach it to, so it is a plain drop rather than a silent no-op. + it("drops a leading sleep, since there is no step whose settling it describes", () => { + const { steps, changes } = liftJourney([ + step({ id: "s1", action: "wait", timeout: 30000, selector: undefined }), + step({ id: "s2", action: "click" }), + ]); + expect(steps.map((s) => s.id)).toEqual(["s2"]); + expect(changes.some((c) => c.kind === "step_dropped")).toBe(true); + expect(changes.some((c) => c.kind === "sleep_converted")).toBe(false); + }); + + it("explains hover separately, since dropping it can change behaviour", () => { + const { changes } = liftJourney([step({ id: "s5", action: "hover" })]); + expect(changes[0].detail).toMatch(/re-record/i); + }); + + // ── Change reporting drives the preview (P2.6.3) ───────────────────────── + + it("reports a change for every modification it makes", () => { + const { changes } = liftJourney([step({ timeout: 10000 })]); + expect(changes.map((c) => c.kind).sort()).toEqual(["locator_created", "timeout_cleared"]); + }); + + it("is a no-op on an already-lifted journey", () => { + const once = liftJourney([step({ timeout: 10000 })]); + const twice = liftJourney(once.steps); + expect(twice.noop).toBe(true); + expect(twice.changes).toEqual([]); + }); + + it("is idempotent", () => { + const once = liftJourney([step({ timeout: 10000 })]).steps; + const twice = liftJourney(once).steps; + expect(twice).toEqual(once); + }); + + it("does not mutate the input", () => { + const original = step({ timeout: 10000 }); + const snapshot = JSON.parse(JSON.stringify(original)); + liftJourney([original]); + expect(JSON.parse(JSON.stringify(original))).toEqual(snapshot); + }); + + // ── needsLift ──────────────────────────────────────────────────────────── + + it("detects when a journey needs lifting", () => { + expect(needsLift([step({ timeout: 10000 })])).toBe(true); + expect(needsLift(liftJourney([step({ timeout: 10000 })]).steps)).toBe(false); + }); + + it("handles an empty journey", () => { + expect(liftJourney([])).toEqual({ steps: [], changes: [], noop: true }); + }); +}); + +describe("locator re-ranking (Phase 2a)", () => { + const step = (locator: BrowserStep["locator"]): BrowserStep => + ({ id: "s1", name: "Click", action: "click", locator }) as BrowserStep; + + it("promotes an unambiguous candidate over a positional one", () => { + const result = liftJourney([ + step({ + candidates: [ + { + kind: "test_attribute", + value: '[data-test="organization-menu-item-label-item-label"] >> nth=1', + }, + { kind: "role", value: 'internal:role=button[name="Save draft"i]' }, + ], + user_override: null, + }), + ]); + + expect(result.steps[0].locator?.candidates[0].value).toBe( + 'internal:role=button[name="Save draft"i]', + ); + expect(result.changes.map((c) => c.kind)).toContain("locator_reranked"); + }); + + it("reports nothing when the bundle is already correctly ordered", () => { + const result = liftJourney([ + step({ + candidates: [ + { kind: "test_attribute", value: '[data-test="save"]' }, + { kind: "css", value: ".btn" }, + ], + user_override: null, + }), + ]); + expect(result.changes.filter((c) => c.kind === "locator_reranked")).toHaveLength(0); + }); + + it("leaves an all-positional bundle alone — ranking cannot help it", () => { + const result = liftJourney([ + step({ + candidates: [ + { kind: "test_attribute", value: '[data-test="row"] >> nth=1' }, + { kind: "css", value: "div >> internal:has-text=/^Acme$/ >> nth=0" }, + ], + user_override: null, + }), + ]); + expect(result.changes.filter((c) => c.kind === "locator_reranked")).toHaveLength(0); + expect(result.steps[0].locator?.candidates[0].value).toBe('[data-test="row"] >> nth=1'); + }); + + it("preserves an author's pin while re-ranking the candidates below it", () => { + const pin = { kind: "css" as const, value: "#pinned" }; + const result = liftJourney([ + step({ + candidates: [ + { kind: "test_attribute", value: '[data-test="row"] >> nth=1' }, + { kind: "role", value: 'internal:role=link[name="Go"i]' }, + ], + user_override: pin, + }), + ]); + expect(result.steps[0].locator?.user_override).toEqual(pin); + }); +}); diff --git a/web/src/utils/synthetics/liftJourney.ts b/web/src/utils/synthetics/liftJourney.ts new file mode 100644 index 0000000000..8b43ab139a --- /dev/null +++ b/web/src/utils/synthetics/liftJourney.ts @@ -0,0 +1,239 @@ +// Copyright 2026 OpenObserve Inc. + +import type { + BrowserStep, + LocatorCandidate, + LocatorKind, + SelectorType, + StepAction, +} from "@/types/synthetics"; +import { + DEFAULT_SETTLE_BUDGET_MS, + MAX_SETTLE_BUDGET_MS, + MIN_SETTLE_BUDGET_MS, + RETIRED_ACTIONS, +} from "@/constants/synthetics"; +import { rankCandidates } from "./locatorStability"; + +/** + * In-place upgrade of a version-1 journey to version 2, without re-recording. + * + * Runs client-side (spec Q-3 / D-11): it is a pure `BrowserStep[] -> BrowserStep[]` + * transform, so the UI can show an author exactly what would change before they + * commit to it — which is what makes the upgrade previewable without a round + * trip. + * + * Partial benefit by design. A lifted journey gets a one-candidate locator + * bundle, so it gains the v2 execution path but not fallback resilience; only + * re-recording produces a real bundle and settle evidence. + */ + +/** The recorder's old hardcoded stamp. Nothing else may be assumed about it. */ +const RECORDER_STAMPED_TIMEOUT_MS = 10000; + +const SELECTOR_TYPE_TO_KIND: Record = { + TestID: "test_attribute", + Role: "role", + Text: "text", + CSS: "css", + XPath: "xpath", +}; + +/** Redundant v1 aliases, collapsed onto the v2 vocabulary. */ +const ACTION_ALIASES: Partial> = { + type: "type", // UI keeps `type`; the wire/schema name is `fill` +}; + +export type LiftChangeKind = + | "locator_created" + | "timeout_cleared" + | "step_dropped" + | "action_renamed" + | "locator_reranked" + | "sleep_converted"; + +export interface LiftChange { + stepId: string; + stepName: string; + kind: LiftChangeKind; + /** Human-readable detail, e.g. why a step was dropped. */ + detail: string; +} + +export interface LiftResult { + steps: BrowserStep[]; + changes: LiftChange[]; + /** True when nothing needed changing — the journey is already v2-shaped. */ + noop: boolean; +} + +function describe(step: BrowserStep): string { + return step.name || step.selector || step.action; +} + +/** + * Why a retired action is dropped rather than translated. + * + * None of these exist in Playwright's recorder action model, so none can be + * expressed as a v2 step. Dropping is the honest outcome, but it is a real + * behaviour change and must be surfaced in the preview rather than applied + * quietly — which is why every drop produces a LiftChange. + */ +const DROP_REASONS: Record = { + wait: "Hard sleeps are removed: the runner now waits for the page itself, with a 30s/60s budget per step instead of a fixed delay.", + scroll: + "Scrolling carries no information — an element is scrolled into view automatically before it is acted on. This step did nothing at run time.", + screenshot: "Screenshots are controlled per run by the capture setting, not per step.", + hover: + "Hover cannot be expressed as a v2 step. If this journey depends on a hover-revealed menu, re-record it instead of lifting.", +}; + +/** + * Convert one v1 step. Returns `null` when the step cannot exist in v2. + */ +function liftStep(step: BrowserStep, changes: LiftChange[]): BrowserStep | null { + const name = describe(step); + + if ((RETIRED_ACTIONS as readonly string[]).includes(step.action)) { + changes.push({ + stepId: step.id, + stepName: name, + kind: "step_dropped", + detail: DROP_REASONS[step.action] ?? `"${step.action}" is not available in version 2.`, + }); + return null; + } + + const lifted: BrowserStep = { ...step }; + + // A single selector becomes a one-candidate bundle. Its kind comes from the + // recorded selector type; an unset type means the selector was authored by + // hand, and CSS is the only safe assumption. + if (step.selector && !step.locator) { + const kind: LocatorKind = step.selectorType ? SELECTOR_TYPE_TO_KIND[step.selectorType] : "css"; + const candidate: LocatorCandidate = { kind, value: step.selector }; + lifted.locator = { candidates: [candidate], user_override: null }; + changes.push({ + stepId: step.id, + stepName: name, + kind: "locator_created", + detail: `Selector kept as the only ${kind} candidate. Re-record to gain fallbacks.`, + }); + } + + // Re-rank an EXISTING bundle so journeys recorded before the positional fix + // benefit without being re-recorded. Ranking on kind alone let a candidate + // ending in `>> nth=` become the primary while an unambiguous alternative sat + // below it — the one failure mode that makes a step act on the wrong element + // and still pass. Reordering is safe by construction: a candidate carrying no + // positional token resolved to exactly one element at record time, so this + // only ever promotes better evidence. + if (step.locator?.candidates?.length) { + const ranked = rankCandidates(step.locator.candidates); + const reordered = ranked.some((candidate, i) => candidate !== step.locator!.candidates[i]); + if (reordered) { + lifted.locator = { ...step.locator, candidates: ranked }; + changes.push({ + stepId: step.id, + stepName: name, + kind: "locator_reranked", + detail: + `Primary locator is now ${ranked[0].kind} \`${ranked[0].value}\`. ` + + `A position-dependent candidate no longer outranks a stable one.`, + }); + } + } + + // Clear the recorder's stamp so the runner's per-category default applies. + // Only the exact stamped value: any other number was an author's deliberate + // choice and is preserved. A hand-set 10000 is indistinguishable from the + // stamp and is cleared too — an acceptable loss, since the runner's default is + // strictly more generous. + if (step.timeout === RECORDER_STAMPED_TIMEOUT_MS) { + delete lifted.timeout; + changes.push({ + stepId: step.id, + stepName: name, + kind: "timeout_cleared", + detail: + "Removed the recorder's 10s stamp — the direct cause of the timeout failures. The runner now allows 60s for navigation and assertions, 30s for interactions.", + }); + } + + const renamed = ACTION_ALIASES[step.action]; + if (renamed && renamed !== step.action) { + lifted.action = renamed; + changes.push({ + stepId: step.id, + stepName: name, + kind: "action_renamed", + detail: `"${step.action}" is now "${renamed}".`, + }); + } + + return lifted; +} + +/** + * Lift a whole journey. Pure — safe to call on every keystroke to drive a + * preview. + */ +export function liftJourney(steps: BrowserStep[]): LiftResult { + const changes: LiftChange[] = []; + const lifted: BrowserStep[] = []; + + for (const step of steps) { + // A sleep is not information about the page, but the DURATION an author + // chose is: it says "this step needs longer than usual". So the sleep step + // disappears while its budget moves onto the step it was waiting for + // (P3.4.3). Dropping it outright would silently tighten the journey. + if (step.action === "wait") { + const previous = lifted[lifted.length - 1]; + const budget = sleepBudgetMs(step); + if (previous && budget !== null) { + previous.settle = { ...previous.settle, budget_ms: budget }; + changes.push({ + stepId: step.id, + stepName: describe(step), + kind: "sleep_converted", + detail: `Removed the ${Math.round(budget / 1000)}s sleep and gave "${describe(previous)}" a ${Math.round(budget / 1000)}s settle budget instead. The run now waits for the page and continues as soon as it is ready, rather than always waiting the full time.`, + }); + continue; + } + // A leading sleep has nothing to attach to: there is no preceding step + // whose settling it could describe. + liftStep(step, changes); + continue; + } + + const next = liftStep(step, changes); + if (next) lifted.push(next); + } + + return { steps: lifted, changes, noop: changes.length === 0 }; +} + +/** + * The sleep duration a `wait` step represents, clamped to what a settle budget + * may be. + * + * A `wait` stores its duration in `timeout` (the probe sleeps for that long) but + * hand-built steps sometimes carry it in `value` instead, so both are read. The + * runner's own default is used when neither says anything — the same 30s the + * probe would have slept. + */ +function sleepBudgetMs(step: BrowserStep): number | null { + const fromValue = Number(step.value); + const raw = step.timeout ?? (Number.isFinite(fromValue) && fromValue > 0 ? fromValue : undefined); + const budget = raw ?? DEFAULT_SETTLE_BUDGET_MS; + if (!Number.isFinite(budget) || budget <= 0) return null; + return Math.min(Math.max(Math.round(budget), MIN_SETTLE_BUDGET_MS), MAX_SETTLE_BUDGET_MS); +} + +/** + * Whether a journey needs lifting at all — used to decide if the upgrade + * affordance is worth showing. + */ +export function needsLift(steps: BrowserStep[]): boolean { + return !liftJourney(steps).noop; +} diff --git a/web/src/utils/synthetics/locatorStability.spec.ts b/web/src/utils/synthetics/locatorStability.spec.ts new file mode 100644 index 0000000000..43c1559f92 --- /dev/null +++ b/web/src/utils/synthetics/locatorStability.spec.ts @@ -0,0 +1,114 @@ +// Copyright 2026 OpenObserve Inc. + +import { describe, expect, it } from "vitest"; +import type { LocatorCandidate } from "@/types/synthetics"; +import { + isFullyPositional, + isPositionalSelector, + LOCATOR_KIND_RANK, + rankCandidates, +} from "./locatorStability"; + +const c = (kind: LocatorCandidate["kind"], value: string): LocatorCandidate => ({ kind, value }); + +describe("isPositionalSelector", () => { + it("recognises every positional shape the generator emits", () => { + // `nth=` engine token — chooseFirstSelector's last resort. + expect(isPositionalSelector('[data-test="row"] >> nth=1')).toBe(true); + // Chained CSS positional — joinTokens. + expect(isPositionalSelector("div >> :nth-match(button, 2)")).toBe(true); + // Ancestor-chain positional — cssFallback. + expect(isPositionalSelector("body > div:nth-child(3) > span")).toBe(true); + }); + + it("does not flag a stable selector", () => { + expect(isPositionalSelector('[data-test="login-sign-in"]')).toBe(false); + expect(isPositionalSelector('internal:role=button[name="Sign In"i]')).toBe(false); + }); + + it("treats `nth` inside recorded text as content, not an index", () => { + expect(isPositionalSelector('internal:text="10th anniversary"i')).toBe(false); + }); +}); + +describe("rankCandidates", () => { + it("puts a verified-unique candidate ahead of a positional one of any kind", () => { + // The observed production shape: the org-switcher's test-attribute + // candidate carries an index, so it is NOT evidence of a unique match. + const ranked = rankCandidates([ + c("test_attribute", '[data-test="organization-menu-item-label-item-label"] >> nth=1'), + c("role", 'internal:role=button[name="Save draft"i]'), + ]); + expect(ranked[0].value).toBe('internal:role=button[name="Save draft"i]'); + }); + + it("keeps the documented kind order among non-positional candidates", () => { + const ranked = rankCandidates([ + c("xpath", "//div/span"), + c("css", ".btn"), + c("text", 'internal:text="Save"i'), + c("role", 'internal:role=button[name="Save"i]'), + c("test_attribute", '[data-test="save"]'), + ]); + expect(ranked.map((x) => x.kind)).toEqual(["test_attribute", "role", "text", "css", "xpath"]); + }); + + it("is stable — ties keep the generator's own order", () => { + const ranked = rankCandidates([c("css", ".a"), c("css", ".b"), c("css", ".c")]); + expect(ranked.map((x) => x.value)).toEqual([".a", ".b", ".c"]); + }); + + it("leaves an all-positional bundle in its original order", () => { + // Ranking cannot help here, which is why the editor has to say so instead. + const input = [ + c("test_attribute", '[data-test="row"] >> nth=1'), + c("css", "div >> internal:has-text=/^Acme$/ >> nth=0"), + ]; + expect(rankCandidates(input).map((x) => x.value)).toEqual(input.map((x) => x.value)); + }); + + it("does not mutate its input", () => { + const input = [c("css", ".b"), c("test_attribute", '[data-test="a"]')]; + const before = input.map((x) => x.value); + rankCandidates(input); + expect(input.map((x) => x.value)).toEqual(before); + }); +}); + +describe("isFullyPositional", () => { + it("is true only when the recorder could not identify the element at all", () => { + expect( + isFullyPositional([ + c("test_attribute", '[data-test="row"] >> nth=1'), + c("css", "div >> internal:has-text=/^Acme$/ >> nth=0"), + ]), + ).toBe(true); + }); + + it("is false when any candidate is unambiguous", () => { + expect( + isFullyPositional([ + c("test_attribute", '[data-test="row"] >> nth=1'), + c("role", 'internal:role=button[name="Save"i]'), + ]), + ).toBe(false); + }); + + it("is false for an empty bundle — nothing to report", () => { + expect(isFullyPositional([])).toBe(false); + }); +}); + +describe("parity with the recorder", () => { + it("ranks kinds identically to crx KIND_RANK", () => { + // Mirrored from crx/src/server/recorder/locatorBundle.ts. If that file + // changes, this assertion is the thing that catches the drift. + expect(LOCATOR_KIND_RANK).toEqual({ + test_attribute: 0, + role: 1, + text: 2, + css: 3, + xpath: 4, + }); + }); +}); diff --git a/web/src/utils/synthetics/locatorStability.ts b/web/src/utils/synthetics/locatorStability.ts new file mode 100644 index 0000000000..519877a156 --- /dev/null +++ b/web/src/utils/synthetics/locatorStability.ts @@ -0,0 +1,87 @@ +// Copyright 2026 OpenObserve Inc. + +import type { LocatorCandidate, LocatorKind } from "@/types/synthetics"; + +/** + * The web mirror of the recorder's locator-stability rules. + * + * Deliberately a mirror rather than a shared package: `crx` is a browser + * extension that cannot take a dependency on this repo, and `probe` is a Lambda + * that cannot either. The three copies are kept in step by tests that assert the + * same cases, which is the same arrangement `globToRegExp` already uses for URL + * pattern matching. + * + * Source of truth for the rules: `crx/src/server/recorder/locatorBundle.ts`. + */ + +/** + * Engine tokens that select by position rather than by identity. + * + * Playwright appends one only when nothing identified the element uniquely, so + * their presence records "the recorder could not tell these elements apart" — + * and their absence proves the selector resolved to exactly one element at + * record time. + */ +const POSITIONAL_TOKEN = /(?:^|>>)\s*nth=|:nth-match\(|:nth-child\(/; + +/** Does this locator depend on how many siblings happen to be on the page? */ +export function isPositionalSelector(selector: string): boolean { + return POSITIONAL_TOKEN.test(selector); +} + +/** + * Most survivable first. + * + * A test attribute exists to be selected on, so it changes only deliberately. A + * role plus accessible name follows the element's meaning rather than its + * markup. Text survives restyling but not copy edits or translation. CSS and + * XPath describe structure, which is exactly what a redesign rewrites. + */ +export const LOCATOR_KIND_RANK: Record = { + test_attribute: 0, + role: 1, + text: 2, + css: 3, + xpath: 4, +}; + +/** + * Sort a stored bundle the way the recorder would sort it today. + * + * Positionality is the PRIMARY key, because Playwright's own scoring says so: + * `kNthScore` is 10000 against kind scores of 500-530, i.e. upstream treats + * "needed an index" as an order of magnitude worse than any distinction between + * kinds. A candidate without an index matched exactly one element when it was + * recorded; one with an index did not. Better evidence of a worse kind still + * beats worse evidence of a better one. + * + * Stable within a group: ties keep the order the generator produced, so a + * deterministic recording renders deterministically. + */ +export function rankCandidates(candidates: LocatorCandidate[]): LocatorCandidate[] { + return candidates + .map((candidate, index) => ({ + candidate, + positional: isPositionalSelector(candidate.value), + index, + })) + .sort( + (a, b) => + Number(a.positional) - Number(b.positional) || + LOCATOR_KIND_RANK[a.candidate.kind] - LOCATOR_KIND_RANK[b.candidate.kind] || + a.index - b.index, + ) + .map((c) => c.candidate); +} + +/** + * Could the recorder identify this element at all? + * + * When every candidate is positional, re-ranking them changes nothing — the + * step is identified by counting siblings whichever one is chosen. That is the + * case the author has to resolve by pinning, and the only case that warrants + * telling them so. + */ +export function isFullyPositional(candidates: LocatorCandidate[]): boolean { + return candidates.length > 0 && candidates.every((c) => isPositionalSelector(c.value)); +} diff --git a/web/src/utils/synthetics/mapRecordedStep.spec.ts b/web/src/utils/synthetics/mapRecordedStep.spec.ts index d5c2fa3a83..98da61c60d 100644 --- a/web/src/utils/synthetics/mapRecordedStep.spec.ts +++ b/web/src/utils/synthetics/mapRecordedStep.spec.ts @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from "vitest"; import type { BrowserStep, WireStep } from "@/types/synthetics"; import { + applyValueToWire, buildWireFromStep, + defaultTimeoutFor, journeyToWireSteps, mapWireStep, mapWireSteps, @@ -18,7 +20,7 @@ describe("mapRecordedStep", () => { url: "https://app.example.com/login", timeout_ms: 10000, }; - expect(mapWireStep(wire)).toEqual({ + expect(mapWireStep(wire, { preserveWire: true })).toEqual({ id: expect.any(String), // mapper assigns a fresh UUID per step action: "navigate", name: "Open login", @@ -47,8 +49,12 @@ describe("mapRecordedStep", () => { startTime: 1718700003100, code: "await page.locator('#login-btn').click();", }; - // wire is spread with the step's own UUID assigned to wire.id. - expect(mapWireStep(wire).wire).toEqual({ ...wire, id: expect.any(String) }); + // wire is spread with the step's own UUID assigned to wire.id. Only the + // live-capture path preserves it — see MapWireStepOptions. + expect(mapWireStep(wire, { preserveWire: true }).wire).toEqual({ + ...wire, + id: expect.any(String), + }); }); describe("buildWireFromStep (reverse mapper for manual steps)", () => { @@ -111,7 +117,11 @@ describe("mapRecordedStep", () => { }); it("should include all steps via journeyToWireSteps (including previously filtered actions)", () => { - const recorded = mapWireStep({ id: "s1", action: "navigate", url: "https://x.test" }); + // Live-captured, so it carries a `wire` to be preserved verbatim below. + const recorded = mapWireStep( + { id: "s1", action: "navigate", url: "https://x.test" }, + { preserveWire: true }, + ); const manual: BrowserStep = { id: "m1", action: "click", @@ -188,8 +198,39 @@ describe("mapRecordedStep", () => { expect(mapWireStep({ id: "s6", action: "waitFor" }).action).toBe("wait"); }); - it("should map setInputFiles to the type action", () => { - expect(mapWireStep({ id: "s7", action: "setInputFiles" }).action).toBe("type"); + // A file upload used to be surfaced as a `type` step, which described neither + // what was recorded nor what would be replayed. It has its own action now. + it("should map setInputFiles to the upload action", () => { + expect(mapWireStep({ id: "s7", action: "setInputFiles" }).action).toBe("upload"); + }); + + // X-9.3 — a checkbox interaction is no longer collapsed to a click, which used + // to make the replayed journey depend on the box's starting state. + it("should keep check and uncheck distinct from click", () => { + expect(mapWireStep({ id: "s8", action: "check" }).action).toBe("check"); + expect(mapWireStep({ id: "s9", action: "uncheck" }).action).toBe("uncheck"); + }); + + it("should carry version-2 evidence through untouched", () => { + const step = mapWireStep({ + id: "s10", + action: "click", + selector: '[data-test="login-sign-in"]', + locator: { + candidates: [ + { kind: "test_attribute", value: '[data-test="login-sign-in"]' }, + { kind: "role", value: 'role=button[name="Sign In"]' }, + ], + }, + settle: { + navigation: { url_pattern: "**/web/**" }, + responses: [{ url_pattern: "**/auth/login", method: "POST", required: false }], + observed_duration_ms: 1800, + }, + }); + expect(step.locator?.candidates).toHaveLength(2); + expect(step.settle?.navigation?.url_pattern).toBe("**/web/**"); + expect(step.settle?.observed_duration_ms).toBe(1800); }); it("should default unknown actions to click and warn", () => { @@ -200,8 +241,31 @@ describe("mapRecordedStep", () => { warn.mockRestore(); }); - it("should default timeout to 30000 when timeout_ms is absent", () => { - expect(mapWireStep({ id: "s9", action: "click" }).timeout).toBe(30000); + // Spec P1.1.2. The mapper must NOT substitute a timeout: absence means "use + // the runner's per-action-category default". Stamping one here would put the + // machine's guess back into stored config, which is what the recorder's + // hardcoded 10000 did — the direct cause of the observed production failures. + it("should leave timeout undefined when timeout_ms is absent", () => { + expect(mapWireStep({ id: "s9", action: "click" }).timeout).toBeUndefined(); + }); + + it("should preserve an author-set timeout", () => { + expect(mapWireStep({ id: "s9", action: "click", timeout_ms: 5000 }).timeout).toBe(5000); + }); + + it("exposes the runner's category defaults for display only", () => { + // 60s for navigate/assert (the slow phases), 30s for interactions. + expect(defaultTimeoutFor("navigate")).toBe(60000); + expect(defaultTimeoutFor("assert")).toBe(60000); + expect(defaultTimeoutFor("click")).toBe(30000); + expect(defaultTimeoutFor("type")).toBe(30000); + }); + + it("should not carry a timeout into the wire step unless the author set one", () => { + expect(buildWireFromStep({ id: "s1", action: "click", code: "" })?.timeout_ms).toBeUndefined(); + expect( + buildWireFromStep({ id: "s1", action: "click", code: "", timeout: 4200 })?.timeout_ms, + ).toBe(4200); }); it("should generate a compact UUIDv7 id when the wire step has none", () => { @@ -218,4 +282,116 @@ describe("mapRecordedStep", () => { expect(steps[0].action).toBe("navigate"); expect(steps[1].action).toBe("click"); }); + + // ── Value round-trip ────────────────────────────────────────────────────── + // The editor keeps one `value` per step; the wire spreads it across + // url/key/text/files/options/value by action. Reading and writing must agree, + // or the editor shows one thing and replay does another. + + it("should surface a recorded select's option as the editor value", () => { + const mapped = mapWireStep({ id: "s1", action: "select", options: ["India"] }); + expect(mapped.value).toBe("India"); + }); + + it.each([ + ["navigate", "url", "https://new.test"], + ["press", "key", "Tab"], + ["assert", "text", "Welcome"], + ["type", "value", "hello"], + ] as const)("applyValueToWire should write a %s value to wire.%s", (action, field, value) => { + const wire = applyValueToWire({ id: "s1", action }, action, value); + expect(wire[field as keyof typeof wire]).toBe(value); + }); + + it("applyValueToWire should write list-valued actions as single-entry lists", () => { + expect(applyValueToWire({ id: "s1", action: "upload" }, "upload", "/tmp/a.pdf").files).toEqual([ + "/tmp/a.pdf", + ]); + expect(applyValueToWire({ id: "s1", action: "select" }, "select", "India").options).toEqual([ + "India", + ]); + }); + + it("applyValueToWire should clear a list-valued field when the value is cleared", () => { + expect( + applyValueToWire({ id: "s1", action: "upload", files: ["/tmp/a.pdf"] }, "upload", "").files, + ).toEqual([]); + }); + + it("applyValueToWire should preserve the extension metadata it does not own", () => { + const wire: WireStep = { + id: "s1", + action: "navigate", + url: "https://old.test", + pageAlias: "page", + framePath: ["main"], + }; + const next = applyValueToWire(wire, "navigate", "https://new.test"); + expect(next.pageAlias).toBe("page"); + expect(next.framePath).toEqual(["main"]); + }); + + it("should round-trip an edited navigate URL back out to replay", () => { + const mapped = mapWireStep({ id: "s1", action: "navigate", url: "https://old.test" }); + const edited: BrowserStep = { + ...mapped, + value: "https://new.test", + wire: applyValueToWire(mapped.wire!, "navigate", "https://new.test"), + }; + expect(journeyToWireSteps([edited])[0].url).toBe("https://new.test"); + }); + + it("should build an assert wire step from the typed assertion's expected value", () => { + const wire = buildWireFromStep({ + id: "s1", + action: "assert", + code: "", + assertion: { kind: "element_text", expected: "Signed in" }, + }); + expect(wire?.text).toBe("Signed in"); + }); +}); + +// ── preserveWire ────────────────────────────────────────────────────────── +// `wire` means two different things. Fresh from the recorder it carries fields +// the v2 schema has no home for (options, text, modifiers, button, position, +// framePath) and is worth keeping. Rebuilt from a SAVED monitor it is strictly +// poorer than what buildWireFromStep reconstructs — so preserving it there +// shadowed the correct reconstruction. That shadowing is SE-24: the extension +// builds its select action from `options`, a stored v2 select carries only +// `value`, so a reloaded select replayed as selectOption([]) — selecting nothing +// while still reading as a pass, because later steps proceeded. +describe("preserveWire", () => { + const storedSelect: WireStep = { + id: "s2", + action: "select", + name: "Pick colour", + value: "Blue", + locator: { candidates: [{ kind: "css", value: "#colour" }] }, + }; + + it("should omit wire by default, so replay is rebuilt from the step", () => { + const [step] = mapWireSteps([storedSelect]); + expect(step.wire).toBeUndefined(); + }); + + it("should preserve wire when the caller opts in (live capture)", () => { + const [step] = mapWireSteps([storedSelect], { preserveWire: true }); + expect(step.wire).toBeDefined(); + expect(step.wire?.action).toBe("select"); + }); + + it("should replay a reloaded select with options, not an empty array (SE-24)", () => { + const [step] = mapWireSteps([storedSelect]); + const [wire] = journeyToWireSteps([step]); + expect(wire.options).toEqual(["Blue"]); + }); + + it("should still replay a live-captured select correctly", () => { + // The recorder's own step carries `options`; opting in must not regress it. + const recorded: WireStep = { ...storedSelect, options: ["Blue"], value: undefined }; + const [step] = mapWireSteps([recorded], { preserveWire: true }); + const [wire] = journeyToWireSteps([step]); + expect(wire.options).toEqual(["Blue"]); + }); }); diff --git a/web/src/utils/synthetics/mapRecordedStep.ts b/web/src/utils/synthetics/mapRecordedStep.ts index 98f920bc78..2e99901bdb 100644 --- a/web/src/utils/synthetics/mapRecordedStep.ts +++ b/web/src/utils/synthetics/mapRecordedStep.ts @@ -9,15 +9,22 @@ const ACTION_MAP: Record = { navigate: "navigate", click: "click", type: "type", + // The version-2 wire name for typing. Both map to the UI's `type`. + fill: "type", press: "press", select: "select", + // Version-2 additions: a checkbox interaction is no longer collapsed to a + // click, which used to make the journey depend on the box's starting state. + check: "check", + uncheck: "uncheck", + upload: "upload", hover: "hover", scroll: "scroll", wait: "wait", waitFor: "wait", assert: "assert", screenshot: "screenshot", - setInputFiles: "type", + setInputFiles: "upload", }; const SELECTOR_TYPE_MAP: Record = { @@ -37,7 +44,20 @@ const WIRE_SELECTOR_TYPE_MAP: Record = TestID: "data-test", }; -const DEFAULT_TIMEOUT = 30000; +/** + * Per-action-category timeout defaults owned by the RUNNER, not written into + * steps. Used here only to show the author what a step will actually get + * (placeholder text) and to warn when they lower it. See spec P1.2, P1.1.5. + */ +export const NAV_ASSERT_TIMEOUT_MS = 60000; +export const INTERACTION_TIMEOUT_MS = 30000; + +/** The timeout this step will get from the runner when none is set explicitly. */ +export function defaultTimeoutFor(action: StepAction): number { + return action === "navigate" || action === "assert" + ? NAV_ASSERT_TIMEOUT_MS + : INTERACTION_TIMEOUT_MS; +} function mapAction(action: string): StepAction { const mapped = ACTION_MAP[action]; @@ -57,13 +77,72 @@ function mapValue(wire: WireStep, action: StepAction): string | undefined { return wire.key ?? wire.value; case "assert": return wire.text ?? wire.value; + case "upload": + // A recorded upload carries its paths in `files`; the editor shows the + // first. Reading `value` alone would render the step blank. + return wire.files?.[0] ?? wire.value; + case "select": + // A recorded select carries the chosen option in `options` + // (actionMapper.ts). Reading `value` alone rendered the Option field + // blank and then saved that blank back over the recorded choice. + return wire.options?.[0] ?? wire.value; default: return wire.value; } } +/** + * Inverse of {@link mapValue}: write the editor's single `value` back into the + * wire field this action actually replays from. + * + * The editor keeps one `value` per step, but the wire spreads it across + * `url`/`key`/`text`/`files`/`options`/`value` by action. Patching only + * `wire.value` — as the editor used to — left the replayed step carrying the + * *recorded* URL or key while the UI and the saved payload showed the edited + * one. Save and preview disagreeing about what the step does is worse than + * either being wrong. + */ +export function applyValueToWire(wire: WireStep, action: StepAction, value: string): WireStep { + switch (action) { + case "navigate": + return { ...wire, url: value }; + case "press": + return { ...wire, key: value }; + case "assert": + return { ...wire, text: value }; + case "upload": + return { ...wire, files: value ? [value] : [] }; + case "select": + return { ...wire, options: value ? [value] : [] }; + default: + return { ...wire, value }; + } +} + +/** + * Options for {@link mapWireStep} and {@link mapWireSteps}. + * + * `preserveWire` keeps the extension's own step on the result for replay. It is + * correct for a LIVE recording, whose wire carries fields the version-2 schema + * has no home for — `options`, `text`, `modifiers`, `button`, `position`, + * `framePath`. + * + * It is wrong when reconstructing from a SAVED monitor. A stored v2 step has none + * of those, so preserving it shadows {@link buildWireFromStep}, which rebuilds + * them correctly from the UI fields. That shadowing is why a reloaded `select` + * replayed as `selectOption([])`: the extension reads `options`, the stored step + * only has `value`, and `buildWireFromStep`'s correct `options: [value]` was + * unreachable while `wire` won. + * + * Default is off, so the storage path is safe by omission and only live capture + * has to opt in. + */ +export interface MapWireStepOptions { + preserveWire?: boolean; +} + /** Convert a single extension {@link WireStep} into the UI-facing {@link BrowserStep}. */ -export function mapWireStep(wire: WireStep): BrowserStep { +export function mapWireStep(wire: WireStep, opts: MapWireStepOptions = {}): BrowserStep { const action = mapAction(wire.action); const id = getUUIDv7(true); return { @@ -73,26 +152,43 @@ export function mapWireStep(wire: WireStep): BrowserStep { selector: wire.selector, selectorType: wire.selector_type ? SELECTOR_TYPE_MAP[wire.selector_type] : undefined, value: mapValue(wire, action), - timeout: wire.timeout_ms ?? DEFAULT_TIMEOUT, + // Undefined means "use the runner's category default" (spec P1.1.2). The + // recorder no longer stamps a value, and substituting one here would put the + // guess back — the previous `?? 30000` was unreachable anyway, because the + // extension always sent 10000. + timeout: wire.timeout_ms, + // Version-2 evidence rides through untouched. It is machine-derived and + // read-only in the editor: the only author channel is pinning a candidate, + // which keeps the stored list byte-comparable for the healing precondition. + locator: wire.locator, + settle: wire.settle, + assertion: wire.assertion, + optional: wire.optional, + alwaysRun: wire.always_run, code: wire.code || "", - // Keep the original extension step untouched for replay (full fidelity). - wire: { - ...wire, - id, - }, + // Keep the original extension step untouched for replay (full fidelity) — + // only when the caller says this wire came from a live recording. See + // MapWireStepOptions. + ...(opts.preserveWire ? { wire: { ...wire, id } } : {}), }; } /** Convert a list of extension wire steps into UI steps. */ -export function mapWireSteps(wires: WireStep[]): BrowserStep[] { - return wires.map(mapWireStep); +export function mapWireSteps(wires: WireStep[], opts: MapWireStepOptions = {}): BrowserStep[] { + return wires.map((w) => mapWireStep(w, opts)); } /** * Reverse of {@link mapWireStep}: reconstruct a replayable {@link WireStep} from a * lean UI step that has no recorded `wire` (i.e. manually added in the editor). - * Mirrors the fields the extension's `buildActionFromStep` consumes. Returns - * `null` for actions the Playwright player can't replay (hover/scroll/wait/screenshot). + * Mirrors the fields the extension's `buildActionFromStep` consumes. + * + * Always returns a wire step. The previous doc claimed it returned `null` for + * hover/scroll/wait/screenshot, but no branch ever did — which made the + * `.filter(w => w != null)` in {@link journeyToWireSteps} dead code and left a + * trap for anyone adding an action. Those four are now RETIRED_ACTIONS: they are + * still sent for replay, where the extension substitutes a no-op and the result + * is reported as "not simulated" rather than as a pass. See spec P1.R.2a/P1.R.3. */ export function buildWireFromStep(step: BrowserStep): WireStep | null { const base: WireStep = { @@ -101,7 +197,15 @@ export function buildWireFromStep(step: BrowserStep): WireStep | null { name: step.name ?? "", selector: step.selector, selector_type: step.selectorType ? WIRE_SELECTOR_TYPE_MAP[step.selectorType] : undefined, - timeout_ms: step.timeout ?? DEFAULT_TIMEOUT, + // Only carry a timeout the author actually set; absence means runner default. + timeout_ms: step.timeout, + // Sent back so the preview can report what it cannot simulate (spec P5.S.3) + // rather than diverging from the probe in silence. + locator: step.locator, + settle: step.settle, + assertion: step.assertion, + optional: step.optional, + always_run: step.alwaysRun, pageAlias: "page", framePath: [], }; @@ -116,10 +220,19 @@ export function buildWireFromStep(step: BrowserStep): WireStep | null { return { ...base, key: step.value }; case "select": return { ...base, options: step.value ? [step.value] : [] }; - case "assert": - // Lean steps can't express assert subtype; default to assertText when a - // value is present, else assertVisible. - return step.value !== undefined && step.value !== "" ? { ...base, text: step.value } : base; + case "check": + case "uncheck": + return base; + case "upload": + return { ...base, files: step.value ? [step.value] : [] }; + case "assert": { + // Lean steps can't express assert subtype; default to assertText when + // there is something to compare, else assertVisible. The typed assertion + // is the author's channel now (the generic Expected input was removed as + // dead — v2 drops `value` on assert), so read `expected` first. + const expected = step.assertion?.expected ?? step.value; + return expected !== undefined && expected !== "" ? { ...base, text: expected } : base; + } case "hover": return base; case "scroll": diff --git a/web/src/utils/synthetics/stepTarget.spec.ts b/web/src/utils/synthetics/stepTarget.spec.ts new file mode 100644 index 0000000000..5fdf2329e1 --- /dev/null +++ b/web/src/utils/synthetics/stepTarget.spec.ts @@ -0,0 +1,86 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; +import type { BrowserStep } from "@/types/synthetics"; +import { stepIsMissingTarget } from "./stepTarget"; + +function step(partial: Partial): BrowserStep { + return { id: "s1", action: "click", ...partial } as BrowserStep; +} + +describe("stepIsMissingTarget", () => { + it("should accept a v1 step that carries a selector", () => { + expect(stepIsMissingTarget(step({ selector: "#login" }))).toBe(false); + }); + + it("should reject a selector-requiring step with neither selector nor locator", () => { + expect(stepIsMissingTarget(step({}))).toBe(true); + expect(stepIsMissingTarget(step({ selector: " " }))).toBe(true); + }); + + it("should accept a v2 step whose target lives in the locator bundle", () => { + expect( + stepIsMissingTarget( + step({ + locator: { + candidates: [{ kind: "test_attribute", value: 'internal:testid=[data-test="login"]' }], + user_override: null, + }, + }), + ), + ).toBe(false); + }); + + it("should accept a v2 step whose only target is a pinned override", () => { + expect( + stepIsMissingTarget( + step({ + locator: { candidates: [], user_override: { kind: "css", value: "#login" } }, + }), + ), + ).toBe(false); + }); + + it("should reject a v2 step whose bundle is empty", () => { + expect(stepIsMissingTarget(step({ locator: { candidates: [], user_override: null } }))).toBe( + true, + ); + }); + + it("should not require a target for actions that carry no element", () => { + expect(stepIsMissingTarget(step({ action: "navigate", value: "https://app.test" }))).toBe( + false, + ); + expect(stepIsMissingTarget(step({ action: "press", value: "Enter" }))).toBe(false); + }); + + it("should not require a target for a page-level assertion", () => { + expect( + stepIsMissingTarget( + step({ action: "assert", assertion: { kind: "url_matches", expected: "/home" } }), + ), + ).toBe(false); + expect(stepIsMissingTarget(step({ action: "assert", assertion: { kind: "page_title" } }))).toBe( + false, + ); + }); + + it("should still require a target for an element-level assertion", () => { + expect( + stepIsMissingTarget(step({ action: "assert", assertion: { kind: "element_visible" } })), + ).toBe(true); + }); +}); diff --git a/web/src/utils/synthetics/stepTarget.ts b/web/src/utils/synthetics/stepTarget.ts new file mode 100644 index 0000000000..0512cb022d --- /dev/null +++ b/web/src/utils/synthetics/stepTarget.ts @@ -0,0 +1,64 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { AssertionKind } from "@/types/synthetics"; +import { SELECTOR_ACTIONS, isPageLevelAssertion } from "@/constants/synthetics"; + +/** + * "Does this step say which element to act on?" — the one rule, in one place. + * + * A version-1 step answers with `selector`; a version-2 step answers with a + * `locator` bundle and carries no `selector` at all (buildV2Steps writes none, + * so a saved v2 check comes back without one). Validating on `selector` alone + * therefore rejected every v2 journey the moment it was reopened for editing — + * the same class of bug as spec P2.5.6, where results rendering read `selector` + * and had to learn to resolve locators too. + * + * Structural parameter types rather than `BrowserStep`: the save-time zod schema + * validates a parsed plain object, not the editor model, and both must decide + * this question identically. + */ +export interface TargetableStep { + action: string; + selector?: string | null; + locator?: { + candidates?: readonly { kind: string; value: string }[] | null; + user_override?: { kind: string; value: string } | null; + } | null; + assertion?: { kind?: string } | null; +} + +/** Whether this action acts on an element and so must name one. */ +export function stepNeedsTarget(step: TargetableStep): boolean { + if (!(SELECTOR_ACTIONS as readonly string[]).includes(step.action)) return false; + // `url_matches` / `page_title` describe the page, not an element — requiring a + // locator for them would make a legitimate assertion unsaveable. + if (step.action === "assert" && isPageLevelAssertion(step.assertion?.kind as AssertionKind)) { + return false; + } + return true; +} + +/** Whether this step names an element by either the v1 or the v2 channel. */ +export function stepHasTarget(step: TargetableStep): boolean { + if (step.locator?.candidates?.length) return true; + if (step.locator?.user_override?.value?.trim()) return true; + return !!step.selector?.trim(); +} + +/** The save-blocking condition: this step must name an element and does not. */ +export function stepIsMissingTarget(step: TargetableStep): boolean { + return stepNeedsTarget(step) && !stepHasTarget(step); +} diff --git a/web/src/views/synthetics/CreateBrowserTest.vue b/web/src/views/synthetics/CreateBrowserTest.vue index 61f73d28ff..5d89931016 100644 --- a/web/src/views/synthetics/CreateBrowserTest.vue +++ b/web/src/views/synthetics/CreateBrowserTest.vue @@ -6,6 +6,7 @@ import { useI18n } from "vue-i18n"; import { useStore } from "vuex"; import type { BrowserCheck, + BrowserStep, SyntheticsLocation, SyntheticsDevice, SyntheticsFolder, @@ -265,7 +266,13 @@ const check = ref({ journey: [], schedule: { type: "interval", intervalValue: 5, intervalUnit: "minutes" }, locations: [], - retries: 0, + // New browser monitors retry once before declaring failure (spec P1.3). + // A single slow render should never page an on-call engineer; passing on retry + // is reported as `warning` (flaky), which never alerts. Deliberately changed + // ONLY here — the buildPayload fallbacks are absent-field defaults, and + // raising those would silently re-interpret existing monitors stored without + // a retries value (P1.3.3). + retries: 1, waitBeforeRetrySecs: 5, alertIfFails: 1, cooldownMins: 5, @@ -433,10 +440,15 @@ async function persist(): Promise { if (errors["name"] || errors["url"] || errors["locations"]) { currentStep.value = 2; } else if (Object.keys(errors).some((k) => k.startsWith("journey."))) { - // Step selector errors — switch to Journey tab and auto-expand + // Step errors — switch to Journey tab and auto-expand currentStep.value = 1; journeyRef.value?.validateStepSelectors?.(); } + // Hand every step-scoped issue to the journey so it renders against the + // field it names, rather than only as the toast below. Done unconditionally: + // a journey issue can coexist with a Details-tab one, and the author should + // find both waiting when they switch tabs. + journeyRef.value?.setStepFieldErrors?.(result.error.issues); toast({ variant: "error", message: t("synthetics.validation.fixHighlightedFields"), @@ -531,7 +543,24 @@ const blockedReason = computed<"incognito" | null>(() => ); function onReplay() { - const steps = journeyToWireSteps(check.value.journey); + runReplay(check.value.journey); +} + +/** + * Replay only the first `upTo` steps (1-based, inclusive). + * + * A single step cannot be replayed on its own: journey state is cumulative and the + * extension starts every replay from the target URL, so step 5 alone would run + * against a fresh page with none of the preceding state. A prefix IS runnable, and + * `replay()` already takes an arbitrary WireStep[] — so slicing the journey is the + * whole implementation, with no extension change. + */ +function onReplayUpTo(upTo: number) { + runReplay(check.value.journey.slice(0, Math.max(1, upTo))); +} + +function runReplay(journey: BrowserStep[]) { + const steps = journeyToWireSteps(journey); if (steps.length === 0) return; recorder .replay( @@ -821,6 +850,7 @@ function onClearResults() { class="h-full!" @need-extension-setup="onNeedExtensionSetup" @replay="onReplay" + @replay-up-to="onReplayUpTo" @stop-replay="onStopReplay" @clear-results="onClearResults" @auto-record-consumed="autoRecord = false" diff --git a/web/src/views/synthetics/MonitorResults.vue b/web/src/views/synthetics/MonitorResults.vue index 18da732c8f..dfb057d6f5 100644 --- a/web/src/views/synthetics/MonitorResults.vue +++ b/web/src/views/synthetics/MonitorResults.vue @@ -77,6 +77,7 @@ along with this program. If not, see . :monitor-status="monitorStatus" :last-triggered-at="lastTriggeredAt" :check-type="checkType" + :retries="retries" @edit="editMonitor" @open-run="openRunDetail" @refresh="refresh" @@ -152,6 +153,9 @@ const orgIdentifier = computed(() => store.state.selectedOrganization?.identifie // and distinguishes "never triggered" from "no runs in this window." const lastTriggeredAt = ref(0); const checkType = ref("browser"); +/** The check's configured retry count. 0 means nothing can ever be observed as + * flaky, so the flakiness tiles have no answer to give rather than a zero. */ +const retries = ref(0); // True once fetchCheck() resolves — the drawer can auto-open from a run/exec // query param before that happens, so RunDetail must not trust checkType's // "browser" default until it's confirmed. @@ -386,6 +390,8 @@ async function fetchCheck() { if (res?.data) { lastTriggeredAt.value = Number(res.data.last_triggered_at) || 0; checkType.value = res.data.type ?? "browser"; + // The flaky tiles are only answerable when the check is allowed to retry. + retries.value = Number(res.data.retries ?? res.data.settings?.retries ?? 0) || 0; } } catch (err: any) { if (err?.response?.status === 404) { diff --git a/web/src/views/synthetics/MonitorRuns.spec.ts b/web/src/views/synthetics/MonitorRuns.spec.ts index 5b04bd98ea..67b58cdc0b 100644 --- a/web/src/views/synthetics/MonitorRuns.spec.ts +++ b/web/src/views/synthetics/MonitorRuns.spec.ts @@ -23,11 +23,14 @@ import { mount, VueWrapper, flushPromises } from "@vue/test-utils"; const $t = (key: string) => key; -const { mockFetchAll, mockRun, mockSyntheticsServiceGetLocations } = vi.hoisted(() => ({ - mockFetchAll: vi.fn().mockResolvedValue(undefined), - mockRun: vi.fn().mockResolvedValue({}), - mockSyntheticsServiceGetLocations: vi.fn().mockResolvedValue({ data: { locations: [] } }), -})); +const { mockFetchAll, mockFetchSteps, mockRun, mockSyntheticsServiceGetLocations } = vi.hoisted( + () => ({ + mockFetchAll: vi.fn().mockResolvedValue(undefined), + mockFetchSteps: vi.fn().mockResolvedValue(undefined), + mockRun: vi.fn().mockResolvedValue({}), + mockSyntheticsServiceGetLocations: vi.fn().mockResolvedValue({ data: { locations: [] } }), + }), +); // ── Mock useSyntheticResults composable with full shape ───────────────── vi.mock("@/composables/useSyntheticResults", () => { @@ -126,6 +129,7 @@ vi.mock("@/composables/useSyntheticResults", () => { }), fetchAll: mockFetchAll, + fetchSteps: mockFetchSteps, cancelAll: vi.fn(), }), }; @@ -162,6 +166,24 @@ vi.mock("@/composables/synthetics/syntheticResultsSchema", () => { return { deviceIconName, deviceLabel, + // Real implementation: the tiles read its output, and a stub returning [] + // would make "no unstable slices" untestable from here. + computePartitionStability: (runs: any[]) => { + const groups = new Map(); + for (const r of runs) { + const key = `${r.location}|${r.device}|${r.browserEngine}`; + groups.set(key, [...(groups.get(key) ?? []), r]); + } + return Array.from(groups.entries()).map(([key, group]) => ({ + key, + location: key.split("|")[0], + device: key.split("|")[1], + engine: key.split("|")[2], + executions: group.length, + transitions: 0, + unstable: false, + })); + }, }; }); @@ -188,6 +210,7 @@ import MonitorRuns from "./MonitorRuns.vue"; // ── Stubs for every child component ───────────────────────────────────── const baseStubs = { OTabs: { + name: "OTabs", template: '
', props: ["modelValue", "class"], }, @@ -401,6 +424,121 @@ describe("MonitorRuns", () => { }); }); + // The step aggregation is the most expensive request this page can issue and + // the Steps tab is the least-visited one, so it is not part of the Overview + // load. Exactly two things may trigger it: opening the tab, and a new time + // window while the tab is open. + describe("steps query is lazy", () => { + /** Switch tabs the way OTabs does — through its v-model. */ + async function switchTab(w: VueWrapper, tab: string) { + await w.findComponent({ name: "OTabs" }).vm.$emit("update:modelValue", tab); + await flushPromises(); + } + + it("should not query steps while the Overview tab is the open one", async () => { + wrapper = mountRuns(); + await flushPromises(); + + const vm = wrapper.vm as any; + await vm.refresh(1_700_000_000_000_000, 1_700_003_600_000_000); + await flushPromises(); + + expect(mockFetchAll).toHaveBeenCalledTimes(1); + expect(mockFetchSteps).not.toHaveBeenCalled(); + }); + + it("should query steps when the user moves to the Steps tab", async () => { + wrapper = mountRuns(); + await flushPromises(); + + const vm = wrapper.vm as any; + await vm.refresh(1_700_000_000_000_000, 1_700_003_600_000_000); + await flushPromises(); + await switchTab(wrapper, "steps"); + + expect(mockFetchSteps).toHaveBeenCalledWith( + "mon-1", + 1_700_000_000_000_000, + 1_700_003_600_000_000, + ); + }); + + it("should not re-query steps when the tab is left and re-opened in the same window", async () => { + wrapper = mountRuns(); + await flushPromises(); + + const vm = wrapper.vm as any; + await vm.refresh(1_700_000_000_000_000, 1_700_003_600_000_000); + await flushPromises(); + + await switchTab(wrapper, "steps"); + await switchTab(wrapper, "overview"); + await switchTab(wrapper, "steps"); + + expect(mockFetchSteps).toHaveBeenCalledTimes(1); + }); + + it("should re-query steps immediately when the window changes while the tab is open", async () => { + wrapper = mountRuns(); + await flushPromises(); + + const vm = wrapper.vm as any; + await vm.refresh(1_700_000_000_000_000, 1_700_003_600_000_000); + await flushPromises(); + await switchTab(wrapper, "steps"); + expect(mockFetchSteps).toHaveBeenCalledTimes(1); + + await vm.refresh(1_700_010_000_000_000, 1_700_013_600_000_000); + await flushPromises(); + + expect(mockFetchSteps).toHaveBeenCalledTimes(2); + expect(mockFetchSteps).toHaveBeenLastCalledWith( + "mon-1", + 1_700_010_000_000_000, + 1_700_013_600_000_000, + ); + }); + + it("should defer the re-query to the next visit when the window changes with the tab closed", async () => { + wrapper = mountRuns(); + await flushPromises(); + + const vm = wrapper.vm as any; + await vm.refresh(1_700_000_000_000_000, 1_700_003_600_000_000); + await flushPromises(); + await switchTab(wrapper, "steps"); + await switchTab(wrapper, "overview"); + + // New window, Steps tab closed — nothing fires yet. + await vm.refresh(1_700_010_000_000_000, 1_700_013_600_000_000); + await flushPromises(); + expect(mockFetchSteps).toHaveBeenCalledTimes(1); + + // …and the stale aggregation is replaced on the next visit, not reused. + await switchTab(wrapper, "steps"); + expect(mockFetchSteps).toHaveBeenCalledTimes(2); + expect(mockFetchSteps).toHaveBeenLastCalledWith( + "mon-1", + 1_700_010_000_000_000, + 1_700_013_600_000_000, + ); + }); + }); + + describe("KPI tiles", () => { + it("should render six tiles and neither Degraded nor Unstable Slices", () => { + wrapper = mountRuns(); + + expect(wrapper.findAll('[data-test^="monitor-runs-kpi-"]')).toHaveLength(6); + expect(wrapper.find('[data-test="monitor-runs-kpi-degraded-runs"]').exists()).toBe(false); + expect(wrapper.find('[data-test="monitor-runs-kpi-unstable-partitions"]').exists()).toBe( + false, + ); + expect(wrapper.text()).not.toContain("synthetics.runs.degradedRuns"); + expect(wrapper.text()).not.toContain("synthetics.runs.unstableSlices"); + }); + }); + describe("emits", () => { it("should emit edit event", () => { wrapper = mountRuns(); diff --git a/web/src/views/synthetics/MonitorRuns.vue b/web/src/views/synthetics/MonitorRuns.vue index b44412150c..b610eb5354 100644 --- a/web/src/views/synthetics/MonitorRuns.vue +++ b/web/src/views/synthetics/MonitorRuns.vue @@ -811,6 +811,27 @@ along with this program. If not, see .
+ +

+ {{ + t("synthetics.runs.stepsWindowTruncated", { + count: stepsCoverage.executions, + from: fmtTimestamp(stepsCoverage.fromMs), + to: fmtTimestamp(stepsCoverage.toMs), + }) + }} +