feat(synthetics): attempts view, evidence panel, alerting, concurrency (#13543)

> 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 <yjdoc2@gmail.com>
Co-authored-by: omkark06 <omkar@zinclabs.io>
Co-authored-by: Harsh Mahajan <115500013+007harshmahajan@users.noreply.github.com>
Co-authored-by: Yashodhan Joshi <yashodhan@openobserve.ai>
Co-authored-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Loakesh Indiran 2026-07-31 06:30:19 +05:30 committed by GitHub
parent 6856e0cfc4
commit db2f0fb62a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 12426 additions and 1330 deletions

View File

@ -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;

View File

@ -52,7 +52,7 @@ pub type RwAHashSet<K> = tokio::sync::RwLock<HashSet<K>>;
pub type RwBTreeMap<K, V> = tokio::sync::RwLock<BTreeMap<K, V>>;
// 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

File diff suppressed because it is too large Load Diff

View File

@ -163,6 +163,46 @@ pub static INGEST_PARQUET_FILES: Lazy<IntGaugeVec> = 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<IntGaugeVec> = 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<IntGaugeVec> = 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<IntGaugeVec> = 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();

View File

@ -35,6 +35,29 @@ pub struct CheckNotification {
pub job_count: i64,
pub error: Option<String>,
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<String>,
}
/// 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 {
<td style="padding:6px 12px;">{target}</td></tr>
<tr><td style="padding:6px 12px;color:#666;">Status</td>
<td style="padding:6px 12px;font-weight:bold;color:{color};">{status}</td></tr>
<tr><td style="padding:6px 12px;color:#666;">Locations checked</td>
<tr><td style="padding:6px 12px;color:#666;">Locations</td>
<td style="padding:6px 12px;">{jobs}</td></tr>
<tr><td style="padding:6px 12px;color:#666;">Time</td>
<td style="padding:6px 12px;">{checked_at}</td></tr>
@ -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),
)

View File

@ -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<String>,
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,

View File

@ -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 <http://www.gnu.org/licenses/>.
//! 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"
);
}
}

View File

@ -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),
]
}
}

View File

@ -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<C: ConnectionTrait>(
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<C: ConnectionTrait>(
conn: &C,
pool: &str,
@ -205,8 +264,16 @@ pub async fn lease_batch<C: ConnectionTrait>(
lease_secs: i64,
browser: Option<bool>,
) -> Result<Vec<LeasedRow>, 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<C: ConnectionTrait>(
/// 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<C: ConnectionTrait>(
conn: &C,
job_id: &str,
status: i32,
result_json: Option<&str>,
now_us: i64,
claimed_by: Option<&str>,
) -> Result<Option<String>, 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::<String>)),
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::<String>)),
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::<String>)),
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<C: ConnectionTrait>(
// ── 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<C: ConnectionTrait>(
conn: &C,
now_us: i64,
@ -353,6 +488,7 @@ pub async fn requeue_expired<C: ConnectionTrait>(
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<C: ConnectionTrait>(
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<C: ConnectionTrait>(
conn: &C,
now_us: i64,
max_attempts: i32,
) -> Result<Vec<DeadLetteredRow>, 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<C: ConnectionTrait>(
return Ok(vec![]);
}
let dead: Vec<DeadLetteredRow> = rows
let candidates: Vec<(DeadLetteredRow, i32)> = rows
.into_iter()
.filter_map(|row| {
Some(DeadLetteredRow {
id: row.try_get::<String>("", "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::<String>("", "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<Value> = dead.iter().map(|r| Value::from(r.id.clone())).collect();
let placeholders: String = ids
.iter()
.enumerate()
.map(|(i, _)| format!("${}", i + 1))
.collect::<Vec<_>>()
.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<C: ConnectionTrait>(
max_attempts: i32,
) -> Result<DispatchFailureOutcome, errors::Error> {
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::<String>()
.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<C: ConnectionTrait>(
conn: &C,
run_id: &str,
) -> Result<Vec<String>, 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<String> = 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<C: ConnectionTrait>(
conn: &C,
now_us: i64,
) -> Result<Vec<PendingBacklogRow>, 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::<i64>("", "pending")
.or_else(|_| row.try_get::<i32>("", "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::<i64>("", "oldest_scheduled_ts")
.or_else(|_| row.try_get::<i32>("", "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<C: ConnectionTrait>(conn: &C, now_us: i64) -> Result<u64, errors::Error> {
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
);
}
}

View File

@ -438,6 +438,94 @@ pub async fn update_last_check_status<C: ConnectionTrait>(
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<C: ConnectionTrait>(
conn: &C,
id: &str,
) -> Result<Option<AlertState>, 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<C: ConnectionTrait>(
conn: &C,
id: &str,
expected: AlertState,
state: AlertState,
) -> Result<bool, errors::Error> {
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<C: ConnectionTrait>(
@ -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,

View File

@ -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 <http://www.gnu.org/licenses/>.
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<string, unknown>) })),
};
}
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);
});
});

View File

@ -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<ReturnType<typeof makeBrowserCheckSaveSchema>>;

View File

@ -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> = {}): 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<string, unknown> = {}) {
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);
});
});

View File

@ -0,0 +1,248 @@
<script setup lang="ts">
// Copyright 2026 OpenObserve Inc.
/**
* What the runner saw when a step failed (spec P5.4, items 35).
*
* The probe has written all of this on every failed run since Phase 5 and the
* results view rendered none of it, so every failure looked the same: a timeout
* string and a screenshot. Neither describes the application the error says
* what the runner was waiting for, and the screenshot shows the symptom.
*
* The three blocks below are ordered by how directly they answer "is this the
* application, or is this us?":
*
* 1. **Locator resolution** answers "locator rot?" mechanically. If candidate 1
* was not found and candidate 3 matched, the markup changed and the step
* healed. If every candidate was not found, the element genuinely was not
* there.
* 2. **Settle signals** are the strongest application-is-at-fault indicator
* already on the record. A stale `**\/auth/login` response says the page
* never got the response it depended on a categorically different
* statement from "an element did not appear".
* 3. **Settle timing** separates slow-but-healthy from broken on one line:
* "settled in 2.3 s when recorded, 41 s today".
*
* No verdict anywhere. The ordering is the guidance presenting evidence and
* letting the engineer conclude is the deliberate posture (spec X-6 permits one
* heuristic in the whole system, and this is not it).
*/
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import type {
FailureDetail,
StepEvidence as StepEvidenceSummary,
} from "@/composables/synthetics/syntheticResultsSchema";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
const props = defineProps<{
detail: FailureDetail;
/** Browser-side evidence for this step, when the probe captured any. */
evidence?: StepEvidenceSummary | null;
/** True when the capture cap bound during the run (X-8.2). */
truncated?: boolean;
}>();
const { t } = useI18n();
const candidates = computed(() => props.detail.candidatesTried ?? []);
const signals = computed(() => props.detail.settleSignals ?? []);
/** True when no candidate resolved — the element was genuinely absent. */
const noneMatched = computed(
() => candidates.value.length > 0 && candidates.value.every((c) => c.outcome === "not_found"),
);
/**
* The step used a fallback, so the markup moved under it.
*
* Only meaningful when something matched at a rank below the primary that is
* the definition of healing, and it is a different diagnosis from "not found".
*/
const healed = computed(() => {
const i = candidates.value.findIndex((c) => c.outcome === "matched");
return i > 0;
});
const staleSignals = computed(() => signals.value.filter((s) => s.status === "stale"));
/** Settling cost far more than it did when recorded — slow, not necessarily broken. */
const settleRatio = computed(() => {
const now = props.detail.settleMs;
const then = props.detail.observedDurationMs;
if (!now || !then || then <= 0) return null;
return now / then;
});
function fmtMs(ms: number | null): string {
if (ms === null) return "—";
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
}
/** Anything worth reading in the browser-side evidence for this step. */
const hasEvidence = computed(() => {
const e = props.evidence;
if (!e) return false;
return e.consoleErrors > 0 || e.pageErrors > 0 || e.requestsFailed > 0 || e.responsesNon2xx > 0;
});
function outcomeVariant(outcome: string): "success" | "error" | "default" {
if (outcome === "matched") return "success";
if (outcome === "not_found") return "error";
return "default";
}
</script>
<template>
<div class="flex flex-col gap-3" data-test="synthetics-run-detail-step-evidence">
<!-- Item 3: which locator candidates were tried, and what happened. -->
<section v-if="candidates.length" data-test="synthetics-run-detail-locator-resolution">
<h4 class="text-text-heading m-0 mb-1 text-xs font-semibold">
{{ t("synthetics.runDetail.locatorResolution") }}
</h4>
<p
v-if="noneMatched"
class="text-text-secondary m-0 mb-1 text-xs"
data-test="synthetics-run-detail-locator-none-matched"
>
{{ t("synthetics.runDetail.locatorNoneMatched") }}
</p>
<p
v-else-if="healed"
class="text-text-secondary m-0 mb-1 text-xs"
data-test="synthetics-run-detail-locator-healed"
>
{{ t("synthetics.runDetail.locatorHealed") }}
</p>
<ul class="m-0 flex list-none flex-col gap-1 p-0">
<li
v-for="(c, i) in candidates"
:key="`${c.kind}-${i}`"
class="flex items-center gap-2 text-xs"
>
<OBadge :variant="outcomeVariant(c.outcome)" size="sm">{{ c.outcome }}</OBadge>
<span class="text-text-secondary shrink-0">{{ c.kind }}</span>
<span class="text-text-body min-w-0 flex-1 truncate font-mono">{{ c.value }}</span>
</li>
</ul>
</section>
<!-- Item 4: which recorded signals arrived, and which did not. -->
<section v-if="signals.length" data-test="synthetics-run-detail-settle-signals">
<h4 class="text-text-heading m-0 mb-1 text-xs font-semibold">
{{ t("synthetics.runDetail.settleSignals") }}
</h4>
<p
v-if="staleSignals.length"
class="text-status-warning-text m-0 mb-1 flex items-start gap-1 text-xs"
data-test="synthetics-run-detail-settle-stale-note"
>
<OIcon name="warning" size="xs" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{ t("synthetics.runDetail.settleStaleNote") }}</span>
</p>
<ul class="m-0 flex list-none flex-col gap-1 p-0">
<li
v-for="(s, i) in signals"
:key="`${s.signal}-${i}`"
class="flex items-center gap-2 text-xs"
>
<OBadge :variant="s.status === 'fired' ? 'success' : 'error'" size="sm">
{{ s.status }}
</OBadge>
<span class="text-text-body min-w-0 flex-1 truncate font-mono">{{ s.signal }}</span>
<span class="text-text-secondary shrink-0">{{ fmtMs(s.waitedMs) }}</span>
</li>
</ul>
</section>
<!-- Item 5: what settling cost today, against what recording observed. -->
<section
v-if="detail.settleMs !== null || detail.observedDurationMs !== null"
data-test="synthetics-run-detail-settle-timing"
>
<h4 class="text-text-heading m-0 mb-1 text-xs font-semibold">
{{ t("synthetics.runDetail.settleTiming") }}
</h4>
<p class="text-text-secondary m-0 text-xs">
{{
t("synthetics.runDetail.settleTimingValue", {
now: fmtMs(detail.settleMs),
recorded: fmtMs(detail.observedDurationMs),
})
}}
<span
v-if="settleRatio && settleRatio >= 2"
class="text-status-warning-text"
data-test="synthetics-run-detail-settle-slower"
>
{{ t("synthetics.runDetail.settleSlower", { times: settleRatio.toFixed(1) }) }}
</span>
</p>
</section>
<!--
What the page SAID and what it ASKED FOR (design §5.2). Items 1-3 above
describe the runner's experience; these describe the application's, which
is the difference between "an element did not appear" and "the login call
returned 503".
-->
<section v-if="hasEvidence" data-test="synthetics-run-detail-app-evidence">
<h4 class="text-text-heading m-0 mb-1 text-xs font-semibold">
{{ t("synthetics.runDetail.applicationEvidence") }}
</h4>
<!-- Non-2xx first: ordering is the guidance, since there is no verdict. -->
<ul
v-if="evidence!.worstResponses.length"
class="m-0 mb-1 flex list-none flex-col gap-1 p-0"
data-test="synthetics-run-detail-worst-responses"
>
<li
v-for="(r, i) in evidence!.worstResponses"
:key="`${r.method}-${r.url}-${i}`"
class="flex items-center gap-2 text-xs"
>
<OBadge variant="error" size="sm">{{ r.status }}</OBadge>
<span class="text-text-secondary shrink-0">{{ r.method }}</span>
<span class="text-text-body min-w-0 flex-1 truncate font-mono">{{ r.url }}</span>
<span v-if="r.count > 1" class="text-text-secondary shrink-0">x{{ r.count }}</span>
</li>
</ul>
<ul
v-if="evidence!.firstConsoleErrors.length"
class="m-0 mb-1 flex list-none flex-col gap-1 p-0"
data-test="synthetics-run-detail-console-errors"
>
<li
v-for="(line, i) in evidence!.firstConsoleErrors"
:key="i"
class="text-text-body font-mono text-xs break-words"
>
{{ line }}
</li>
</ul>
<p class="text-text-secondary m-0 text-xs">
{{
t("synthetics.runDetail.evidenceCounts", {
consoleErrors: evidence!.consoleErrors,
pageErrors: evidence!.pageErrors,
failed: evidence!.requestsFailed,
nonOk: evidence!.responsesNon2xx,
})
}}
</p>
</section>
<!-- X-8.2: reduced fidelity is reported, never silent. -->
<p
v-if="truncated"
class="text-status-warning-text m-0 flex items-start gap-1 text-xs"
data-test="synthetics-run-detail-evidence-truncated"
>
<OIcon name="warning" size="xs" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{ t("synthetics.runDetail.evidenceTruncated") }}</span>
</p>
</div>
</template>

View File

@ -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]);
});
});

View File

@ -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<StepReplayResult | undefined>(() => {
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<Set<string>>(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<Map<string, Record<string, string>>>(new Map());
/** Record zod issues whose path points at a journey step field. */
function setStepFieldErrors(issues: { path: PropertyKey[]; message: string }[]) {
const next = new Map<string, Record<string, string>>();
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<string>();
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<BrowserStep>) {
// 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() {
</div>
</div>
<!-- Version-2 upgrade offer. Sits above the steps because saving is refused
while a retired action remains, and the remedy belongs next to the
problem rather than in a menu. -->
<UpgradeJourneyBanner
v-if="!readonly"
:steps="modelValue"
@upgrade="(steps) => emit('update:modelValue', steps)"
/>
<!-- A journey that verifies nothing can pass against a broken application,
so the author is offered an assertion rather than left to think of it. -->
<ZeroAssertionNotice
v-if="!readonly"
:steps="modelValue"
@add-assertion="(step) => emit('update:modelValue', [...modelValue, step])"
/>
<!-- Zero test attributes across a whole recording is a misconfiguration,
not a property of the page, and it is otherwise completely silent. -->
<TestIdMisconfiguredNotice
v-if="!readonly"
:steps="modelValue"
:test-id-attr="testIdAttr ?? DEFAULT_TEST_ID_ATTR"
/>
<!-- Incognito blocked warning card (pre-flight failure) -->
<div
v-if="blockedReason === 'incognito'"
@ -900,105 +989,50 @@ function openChromeExtensions() {
@insert-below="handleInsertBelow"
@retry-replay="emit('replay')"
>
<!-- Inline editor (expanded content) -->
<!-- Inline editor (expanded content) the same component the recording
panel renders, so an author sees the same fields either way -->
<template #expansion="{ row }">
<div class="flex flex-col gap-3 px-8 pt-3 pb-3">
<!-- Action + Step name in one row -->
<div class="flex gap-2">
<OSelect
:model-value="row.action"
:label="t('synthetics.journey.actionLabel')"
:options="actionOptions"
class="w-50! shrink-0"
:error="firstStepError && props.modelValue[0]?.id === row.id"
:error-message="
firstStepError && props.modelValue[0]?.id === row.id
? t('synthetics.validation.firstStepMustNavigate')
: ''
"
data-test="synthetics-journey-step-action-select"
@update:model-value="
(v: any) => {
handleStepUpdate(row, { action: v as any });
clearFirstStepError();
}
"
/>
<OInput
:model-value="row.name ?? ''"
:label="t('synthetics.journey.stepNameOptional')"
:placeholder="t('synthetics.journey.stepNamePlaceholder')"
class="w-100!"
data-test="synthetics-journey-step-name-input"
@update:model-value="(v: any) => handleStepUpdate(row, { name: v })"
/>
</div>
<!-- Selector type + selector (when applicable) -->
<template v-if="selectorActions.includes(row.action)">
<div class="flex w-fit! gap-2">
<OSelect
:model-value="row.selectorType ?? 'CSS'"
:label="t('synthetics.journey.selectorTypeLabel')"
:options="selectorTypeOptions"
class="w-50! shrink-0"
data-test="synthetics-journey-step-selector-type-select"
@update:model-value="(v: any) => handleStepUpdate(row, { selectorType: v })"
/>
<OInput
:model-value="row.selector ?? ''"
:label="t('synthetics.journey.selectorLabel')"
placeholder="#my-button or .class-name"
class="w-100!"
:required="true"
:error="selectorErrors.has(row.id)"
:error-message="
selectorErrors.has(row.id)
? t('synthetics.validation.selectorRequired', {
step:
row.name ||
t('synthetics.results.steps.step', {
step: props.modelValue.indexOf(row) + 1,
}),
})
: ''
"
data-test="synthetics-journey-step-selector-input"
@update:model-value="
(v: any) => {
handleStepUpdate(row, { selector: v });
clearSelectorError(row.id);
}
"
/>
</div>
</template>
<!-- Value (action-specific label) -->
<OInput
v-if="valueActions.includes(row.action)"
:model-value="row.value ?? ''"
:label="valueActionLabel(row.action)"
:placeholder="valueActionLabel(row.action)"
:class="valueWidthClass(row.action)"
data-test="synthetics-journey-step-value-input"
@update:model-value="(v: any) => handleStepUpdate(row, { value: v })"
>
<template v-if="valueTooltip(row.action)" #tooltip>
<OTooltip :content="valueTooltip(row.action)!" />
</template>
</OInput>
<!-- Timeout -->
<OInput
:model-value="String(row.timeout ?? '')"
:label="t('synthetics.journey.timeoutLabel')"
:placeholder="t('synthetics.journey.timeoutPlaceholder')"
type="number"
class="w-50!"
data-test="synthetics-journey-step-timeout-input"
@update:model-value="
(v: any) => handleStepUpdate(row, { timeout: v ? Number(v) : undefined })
"
/>
</div>
<!-- What the runner saw, when this step is the one that failed. Above the
editor because it is the reason the author opened the row. -->
<BrowserJourneyStepError
v-if="failedResultFor(row)"
class="mx-8 mt-3"
:result="failedResultFor(row)!"
:step-number="stepNumberOf(row)"
@retry-replay="emit('replay-up-to', stepNumberOf(row))"
/>
<BrowserJourneyStepEditor
class="px-8 pt-3 pb-3"
:step="row"
:action-error-message="
(firstStepError && props.modelValue[0]?.id === row.id
? t('synthetics.validation.firstStepMustNavigate')
: '') || fieldError(row.id, 'action')
"
:name-error-message="fieldError(row.id, 'name')"
:selector-error-message="
(selectorErrors.has(row.id)
? t('synthetics.validation.selectorRequired', {
step:
row.name ||
t('synthetics.results.steps.step', {
step: props.modelValue.indexOf(row) + 1,
}),
})
: '') || fieldError(row.id, 'selector')
"
:value-error-message="fieldError(row.id, 'value')"
:expected-error-message="fieldError(row.id, 'assertion.expected')"
@update:step="(next: BrowserStep) => handleStepReplace(row, next)"
@action-edited="
clearFirstStepError();
clearFieldError(row.id, 'action');
"
@selector-edited="
clearSelectorError(row.id);
clearFieldError(row.id, 'selector');
"
/>
</template>
</JourneySteps>

View File

@ -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<string, unknown> },
});
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",
]);
});
});

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
// Copyright 2026 OpenObserve Inc.
/**
* The assertion editor for an `assert` step.
*
* A journey that only clicks can click its way through a broken application and
* still pass. This is where an author turns a sequence of interactions into a
* statement about an outcome.
*
* The kind set is closed and mirrors the server's (spec P5.1): the probe fails
* an unknown kind rather than passing it, so anything not in this list would
* surface as every run failing instead of as a validation error at save time.
*/
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import type { AssertionKind, StepAssertion } from "@/types/synthetics";
import {
ASSERTION_KINDS,
assertionNeedsAttribute,
assertionNeedsExpected,
} from "@/constants/synthetics";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
const props = defineProps<{
assertion?: StepAssertion;
/** Validation from the host view; absent in contexts that do not validate. */
expectedErrorMessage?: string;
}>();
const emit = defineEmits<{ "update:assertion": [value: StepAssertion] }>();
const { t } = useI18n();
/** An assert step with no typed assertion keeps its original meaning. */
const current = computed<StepAssertion>(() => props.assertion ?? { kind: "element_visible" });
const kindOptions = computed(() =>
ASSERTION_KINDS.map((kind) => ({
label: t(`synthetics.journey.assertionKind.${kind}`),
value: kind,
})),
);
const kindComputed = computed({
get: () => current.value.kind,
set: (v: string | number | boolean | null | undefined) => {
const kind = (v as AssertionKind) ?? "element_visible";
// Values that no longer mean anything for the new kind are dropped rather
// than carried invisibly a stale `attribute` on a `page_title` assertion
// would be refused by validation with no visible cause.
emit("update:assertion", {
kind,
...(assertionNeedsExpected(kind) && { expected: current.value.expected ?? "" }),
...(assertionNeedsAttribute(kind) && { attribute: current.value.attribute ?? "" }),
});
},
});
const expectedComputed = computed({
get: () => current.value.expected ?? "",
set: (v: string) => emit("update:assertion", { ...current.value, expected: v }),
});
const attributeComputed = computed({
get: () => current.value.attribute ?? "",
set: (v: string) => emit("update:assertion", { ...current.value, attribute: v }),
});
const showExpected = computed(() => assertionNeedsExpected(current.value.kind));
const showAttribute = computed(() => assertionNeedsAttribute(current.value.kind));
const expectedPlaceholder = computed(() =>
t(`synthetics.journey.assertionExpectedPlaceholder.${current.value.kind}`),
);
</script>
<template>
<div class="flex flex-col gap-2" data-test="synthetics-journey-step-assertion">
<OSelect
v-model="kindComputed"
:label="t('synthetics.journey.assertionKindLabel')"
:options="kindOptions"
data-test="synthetics-journey-step-assertion-kind-select"
/>
<OInput
v-if="showAttribute"
v-model="attributeComputed"
:label="t('synthetics.journey.assertionAttributeLabel')"
placeholder="href"
data-test="synthetics-journey-step-assertion-attribute-input"
/>
<OInput
v-if="showExpected"
v-model="expectedComputed"
:label="t('synthetics.journey.assertionExpectedLabel')"
:placeholder="expectedPlaceholder"
:error="!!expectedErrorMessage"
:error-message="expectedErrorMessage ?? ''"
data-test="synthetics-journey-step-assertion-expected-input"
/>
</div>
</template>

View File

@ -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<string, unknown> },
});
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 <input> to set a
// value — the pattern the pinning tests above already use.
function overrideInput(wrapper: ReturnType<typeof render>) {
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<typeof render>, 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");
});
});

View File

@ -0,0 +1,307 @@
<script setup lang="ts">
// Copyright 2026 OpenObserve Inc.
/**
* The Locator block for a version-2 step.
*
* Governing rule (spec P2.5.0): **candidates are evidence, `user_override` is
* intent.** The candidate list is machine-derived from the recording session and
* is read-only here. The only way for an author to say "use this one" is to pin
* it which is what makes hand-edits harmless, lets self-healing compare the
* stored list byte-for-byte, and makes "never heal a pinned step" fall out for
* free rather than needing a separate flag.
*
* Pinning is deliberately not reordering (P2.5.1). The two look similar and mean
* different things: reordering says "prefer this, but keep falling back", while
* pinning says "use exactly this and nothing else". Offering only one of them
* keeps the stored intent unambiguous.
*/
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { LocatorCandidate, StepLocator } from "@/types/synthetics";
import { isFullyPositional } from "@/utils/synthetics/locatorStability";
import { deriveLocatorKind } from "@/utils/synthetics/deriveLocatorKind";
import OBadge from "@/lib/core/Badge/OBadge.vue";
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 OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
const props = defineProps<{ locator: StepLocator }>();
const emit = defineEmits<{ "update:locator": [value: StepLocator] }>();
const { t } = useI18n();
const pinned = computed(() => props.locator.user_override ?? null);
const candidates = computed(() => props.locator.candidates ?? []);
/** What the runner would actually use: the pin if there is one, else the primary. */
const effective = computed<LocatorCandidate | null>(
() => pinned.value ?? candidates.value[0] ?? null,
);
/**
* Everything after the primary shown in full, never behind a disclosure.
*
* P2.5/T5 put these behind an `OCollapsible "N fallbacks"`. That was written when
* the editor was a flat column of eight controls and hiding the list was one of the
* few ways to keep the block short. Field grouping replaced that constraint, and a
* bare count advertised nothing, so the click bought the author no information: the
* list is what the runner will actually try if the primary stops matching, and the
* ordering only carries meaning when it can be seen.
*/
const fallbacks = computed(() => candidates.value.slice(1));
/**
* A bundle with no candidates and no pin a step added by hand rather than
* recorded. 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: labelling it
* "use a different locator" would be wrong when there is nothing to differ from.
*
* It is also required in this state. The block only renders when the step needs a
* target (`stepNeedsTarget`), so its presence means "a target is mandatory here",
* and this input is the only way to supply one.
*/
const isEmpty = computed(() => !candidates.value.length && !pinned.value);
/**
* Every candidate identifies the element by counting siblings.
*
* Playwright appends a positional token only when nothing identified the element
* uniquely, so when all of them carry one the recorder is saying it could not
* tell these elements apart. Re-ranking cannot help there is nothing better to
* promote which is exactly why this has to be said rather than sorted away.
* A pinned step is excluded: the author has already answered the question.
*/
const allPositional = computed(() => !pinned.value && isFullyPositional(candidates.value));
const overrideDraft = ref("");
function pin(candidate: LocatorCandidate) {
emit("update:locator", { ...props.locator, user_override: { ...candidate } });
}
function unpin() {
emit("update:locator", { ...props.locator, user_override: null });
}
/**
* Free text sets `user_override` it never edits a candidate.
*
* Editing a candidate in place would corrupt the recorded evidence: the stored
* list is what a later healing pass compares against to decide whether the page
* has changed, and an author's correction is not evidence of anything the
* recorder saw.
*/
function applyOverride() {
const value = overrideDraft.value.trim();
if (!value) return;
// The kind is READ from the value, never chosen (D3). `kind` labels a locator; it
// does not parse it both consumers hand `value` to page.locator() so a picker
// that only set `kind` would store role= semantics on a bare CSS string.
emit("update:locator", {
...props.locator,
user_override: { kind: deriveLocatorKind(value), value },
});
overrideDraft.value = "";
}
/**
* The kind the current draft would be stored as feedback, not a control, so it
* can never be set to something the value contradicts.
*
* Always derived from what is in the box. A candidate's stored kind is deliberately
* not carried across by "start from this": the badge must describe the string the
* author can see and may since have edited.
*/
const draftKind = computed(() =>
overrideDraft.value.trim() ? deriveLocatorKind(overrideDraft.value.trim()) : null,
);
/** Prefill the override with a recorded candidate, as a starting point to edit. */
function startFrom(candidate: LocatorCandidate) {
overrideDraft.value = candidate.value;
}
function isPinnedCandidate(candidate: LocatorCandidate): boolean {
return pinned.value?.kind === candidate.kind && pinned.value?.value === candidate.value;
}
</script>
<template>
<div class="flex w-full flex-col gap-2" data-test="synthetics-journey-step-locator">
<!-- In the empty state the block IS a single input, and that input already
carries this heading as its label showing both would say it twice. -->
<div v-if="!isEmpty" class="flex items-center gap-2">
<span class="text-text-secondary text-xs">{{ t("synthetics.journey.locatorLabel") }}</span>
<OTooltip :content="t('synthetics.journey.locatorHelp')">
<OIcon name="info-outline" size="xs" class="text-text-secondary" aria-hidden="true" />
</OTooltip>
</div>
<!-- The effective locator: what this step will actually use. Both badges sit on
the left so the row's action group lines up with every fallback row below
it, whatever combination of buttons a row happens to carry. -->
<div
v-if="effective"
class="border-border-default rounded-default flex w-full items-center gap-2 border px-2 py-2"
data-test="synthetics-journey-step-locator-primary"
>
<OBadge variant="default" size="sm">{{
t(`synthetics.journey.locatorKind.${effective.kind}`)
}}</OBadge>
<OBadge v-if="pinned" variant="default" size="sm">{{
t("synthetics.journey.locatorPinned")
}}</OBadge>
<OTooltip :content="effective.value" interactive>
<span class="text-text-body min-w-0 flex-1 truncate font-mono text-xs">
{{ effective.value }}
</span>
</OTooltip>
<div class="ml-auto flex shrink-0 items-center gap-1">
<OButton
variant="ghost"
size="xs"
data-test="synthetics-journey-step-locator-start-from-primary-btn"
@click="startFrom(effective)"
>
{{ t("synthetics.journey.locatorStartFromThis") }}
</OButton>
<OButton
v-if="pinned"
variant="ghost"
size="xs"
data-test="synthetics-journey-step-locator-unpin-btn"
@click="unpin"
>
{{ t("synthetics.journey.locatorUnpin") }}
</OButton>
<OButton
v-else-if="candidates.length"
variant="ghost"
size="xs"
data-test="synthetics-journey-step-locator-pin-primary-btn"
@click="pin(candidates[0])"
>
{{ t("synthetics.journey.locatorPin") }}
</OButton>
</div>
</div>
<!--
Phase 2a: the recorder could not identify this element at all. Ranking is
a no-op here, so the author is the only thing that can resolve it.
-->
<p
v-if="allPositional"
class="text-status-warning-text m-0 flex items-start gap-1 text-xs"
data-test="synthetics-journey-step-locator-positional-warning"
>
<OIcon name="warning" size="xs" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{ t("synthetics.journey.locatorAllPositionalWarning") }}</span>
</p>
<!-- A pinned step never falls back, so the list is shown inert. -->
<p
v-if="pinned"
class="text-text-secondary m-0 text-xs"
data-test="synthetics-journey-step-locator-pinned-note"
>
{{ t("synthetics.journey.locatorPinnedNote") }}
</p>
<!-- Every remaining candidate, in full. Says what the list is FOR rather than
how many entries it has: the ordering is the runner's own fallback order,
and a count communicated none of that. A pinned step never falls back, so
the note above stands in for the lead-in and the rows render inert. -->
<template v-if="fallbacks.length">
<p
v-if="!pinned"
class="text-text-secondary m-0 text-xs"
data-test="synthetics-journey-step-locator-fallbacks-lead"
>
{{ t("synthetics.journey.locatorFallbacksLead") }}
</p>
<div
class="flex w-full flex-col"
:class="{ 'opacity-50': !!pinned }"
data-test="synthetics-journey-step-locator-fallbacks"
>
<div
v-for="candidate in fallbacks"
:key="`${candidate.kind}:${candidate.value}`"
class="flex w-full items-center gap-2 px-2 py-1"
>
<OBadge variant="default" size="sm">{{
t(`synthetics.journey.locatorKind.${candidate.kind}`)
}}</OBadge>
<OTooltip :content="candidate.value" interactive>
<span class="text-text-secondary min-w-0 flex-1 truncate font-mono text-xs">
{{ candidate.value }}
</span>
</OTooltip>
<div class="ml-auto flex shrink-0 items-center gap-1">
<OButton
variant="ghost"
size="xs"
data-test="synthetics-journey-step-locator-start-from-btn"
@click="startFrom(candidate)"
>
{{ t("synthetics.journey.locatorStartFromThis") }}
</OButton>
<OButton
variant="ghost"
size="xs"
:disabled="!!pinned && !isPinnedCandidate(candidate)"
data-test="synthetics-journey-step-locator-pin-btn"
@click="pin(candidate)"
>
{{ t("synthetics.journey.locatorPin") }}
</OButton>
</div>
</div>
</div>
</template>
<!-- Free text is intent, so it sets the pin rather than editing evidence. Last
in the block: it is the author's own entry, after every recorded one. -->
<div class="flex w-full items-end gap-2">
<OInput
v-model="overrideDraft"
:label="
isEmpty
? t('synthetics.journey.locatorEmptyLabel')
: t('synthetics.journey.locatorOverrideLabel')
"
:placeholder="
isEmpty
? t('synthetics.journey.locatorEmptyPlaceholder')
: t('synthetics.journey.locatorOverridePlaceholder')
"
:required="isEmpty"
class="flex-1"
data-test="synthetics-journey-step-locator-override-input"
@keyup.enter="applyOverride"
/>
<!-- Read from the value, not chosen: `kind` labels a locator, it does not
parse it, so a picker that only set `kind` would store a contradiction. -->
<OTooltip v-if="draftKind" :content="t('synthetics.journey.locatorDerivedKindHelp')">
<OBadge
variant="default"
size="sm"
data-test="synthetics-journey-step-locator-derived-kind"
>
{{ t(`synthetics.journey.locatorKind.${draftKind}`) }}
</OBadge>
</OTooltip>
<OButton
variant="secondary"
size="sm"
:disabled="!overrideDraft.trim()"
data-test="synthetics-journey-step-locator-override-btn"
@click="applyOverride"
>
{{ t("synthetics.journey.locatorOverrideApply") }}
</OButton>
</div>
</div>
</template>

View File

@ -17,7 +17,7 @@ const OInputStub = {
props: ["modelValue", "label", "placeholder", "type"],
emits: ["update:modelValue"],
template:
'<input v-bind="$attrs" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" :data-label="label" />',
'<input v-bind="$attrs" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" :data-label="label" :placeholder="placeholder" />',
};
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);
});
});
});

View File

@ -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<BrowserStep>) {
// 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() {
</div>
</div>
<!-- Inline editor (expanded) -->
<div v-if="expanded" class="flex w-32! flex-col gap-3 px-8 pt-3 pb-3">
<!-- Action select -->
<OSelect
v-model="actionComputed"
:label="t('synthetics.journey.actionLabel')"
:options="actionOptions"
class="w-[25rem]!"
data-test="synthetics-journey-step-action-select"
/>
<!-- Step name -->
<OInput
v-model="nameComputed"
:label="t('synthetics.journey.stepNameOptional')"
:placeholder="t('synthetics.journey.stepNamePlaceholder')"
data-test="synthetics-journey-step-name-input"
/>
<!-- Selector type + selector (when applicable) -->
<template v-if="showSelector">
<div class="flex gap-2">
<OSelect
v-model="selectorTypeComputed"
:label="t('synthetics.journey.selectorTypeLabel')"
:options="selectorTypeOptions"
class="w-[25rem]! shrink-0"
data-test="synthetics-journey-step-selector-type-select"
/>
<OInput
v-model="selectorComputed"
:label="t('synthetics.journey.selectorLabel')"
placeholder="#my-button or .class-name"
class="flex-1"
data-test="synthetics-journey-step-selector-input"
/>
</div>
</template>
<!-- Value (action-specific label) -->
<OInput
v-if="showValue"
v-model="valueComputed"
:label="valueLabel"
:placeholder="valueLabel"
data-test="synthetics-journey-step-value-input"
/>
<!-- Timeout -->
<OInput
v-model="timeoutComputed"
:label="t('synthetics.journey.timeoutLabel')"
:placeholder="t('synthetics.journey.timeoutPlaceholder')"
type="number"
data-test="synthetics-journey-step-timeout-input"
/>
</div>
<!-- Inline editor (expanded) same component the edit view renders, so
both surfaces always offer the same fields -->
<BrowserJourneyStepEditor
v-if="expanded"
class="px-8 pt-3 pb-3"
:step="step"
@update:step="emit('update:step', $event)"
/>
</div>
</template>

View File

@ -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<string, unknown> },
});
function render(step: Partial<BrowserStep> = {}) {
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<BrowserStep>, errors: Record<string, string>) {
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<typeof render>) =>
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<typeof render>) {
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<typeof render>) {
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<typeof render>, 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,
);
});
});

View File

@ -0,0 +1,579 @@
<script setup lang="ts">
// Copyright 2026 OpenObserve Inc.
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { BrowserStep, SettleResponse, StepAssertion, StepLocator } from "@/types/synthetics";
import {
ACTION_LABELS,
DEFAULT_SETTLE_BUDGET_MS,
MAX_SETTLE_BUDGET_MS,
MAX_STEP_TIMEOUT_MS,
MIN_SETTLE_BUDGET_MS,
VALUE_ACTIONS,
VALUE_LABELS,
VALUE_TOOLTIP_MAP,
actionOptions,
} from "@/constants/synthetics";
import { applyValueToWire, defaultTimeoutFor } from "@/utils/synthetics/mapRecordedStep";
import { stepNeedsTarget } from "@/utils/synthetics/stepTarget";
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 OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
import type { CheckboxModelValue } from "@/lib/forms/Checkbox/OCheckbox.types";
import BrowserJourneyLocator from "./BrowserJourneyLocator.vue";
import BrowserJourneyAssertion from "./BrowserJourneyAssertion.vue";
/**
* The expanded editor for one journey step every author-editable field a step
* has, in one place.
*
* It exists as its own component because there used to be two of these: the one
* inside BrowserJourneyStep (used by the live recording panel) and a second,
* thinner copy inlined in BrowserJourney's `#expansion` slot (used when editing
* a saved check). The copies had drifted the edit view rendered no locator
* bundle, no settle block, no assertion editor and no optional/always-run
* checkboxes, and synced fewer fields back into the wire step so which fields
* an author could see depended on how they had arrived at the step. One
* component is the only way that stays fixed.
*/
const props = defineProps<{
step: BrowserStep;
/** Validation from the host view; absent in contexts that do not validate. */
actionErrorMessage?: string;
nameErrorMessage?: string;
selectorErrorMessage?: string;
valueErrorMessage?: string;
expectedErrorMessage?: string;
}>();
const emit = defineEmits<{
"update:step": [value: BrowserStep];
/** Host views clear their own validation state on edit. */
"action-edited": [];
"selector-edited": [];
}>();
const { t } = useI18n();
/**
* Apply an edit and keep the recorded wire step in sync.
*
* `journeyToWireSteps` prefers `wire` over the UI fields, so an edit that does
* not land in `wire` is discarded at replay time. The wire spreads the editor's
* single `value` across url/key/text/files/options by action, which is why the
* write goes through `applyValueToWire` rather than assigning `wire.value`.
*/
function update(patch: Partial<BrowserStep>) {
let wire = props.step.wire ? { ...props.step.wire } : undefined;
if (wire) {
if (patch.name !== undefined) wire.name = patch.name;
// No `selector` / `selectorType` sync: the editor has no control that can
// produce either patch since the v1 authoring path was deleted. A version-2
// step names its element through `locator`, handled below.
if (patch.value !== undefined) wire = applyValueToWire(wire, props.step.action, patch.value);
if (patch.timeout !== undefined) wire.timeout_ms = patch.timeout;
// The preview needs these to report what it cannot simulate, so they travel
// with the replayed step rather than being dropped on edit.
if (patch.locator !== undefined) wire.locator = patch.locator;
if (patch.settle !== undefined) wire.settle = patch.settle;
if (patch.assertion !== undefined) wire.assertion = patch.assertion;
if (patch.optional !== undefined) wire.optional = patch.optional;
if (patch.alwaysRun !== undefined) wire.always_run = patch.alwaysRun;
if (patch.action !== undefined) wire = undefined; // action changed wire metadata is no longer accurate
}
emit("update:step", { ...props.step, wire, ...patch });
}
// Field bindings
/**
* Does this step name an element?
*
* One rule, shared with the save-time validator (`stepIsMissingTarget`), so the
* form cannot ask for a target the validator ignores nor omit one it requires.
* Page-level assertions (`url_matches`, `page_title`) describe the page and need
* no element at all, which is why this is not simply `SELECTOR_ACTIONS.includes`.
*
* It also governs requiredness: the block renders only when a target is needed,
* so whenever it is visible a target is mandatory. There is no separate
* conditional-`required` binding to keep in step.
*/
const showTarget = computed(() => stepNeedsTarget(props.step));
/**
* Never hand `BrowserJourneyLocator` a fresh object literal from the template
* a new identity on every render defeats its prop watchers. A step that somehow
* carries no bundle falls back to an empty one, computed once.
*/
const effectiveLocator = computed<StepLocator>(
() => props.step.locator ?? { candidates: [], user_override: null },
);
const showValue = computed(() => VALUE_ACTIONS.includes(props.step.action));
const valueLabel = computed(
() => VALUE_LABELS[props.step.action] || t("synthetics.journey.valueFallback"),
);
const valueTooltip = computed(() => VALUE_TOOLTIP_MAP[props.step.action]);
/**
* Did changing the action just discard a recorded wire step? (SE-11 / D9)
*
* Discarding is correct the wire's payload belongs to the OLD action, so a renamed
* `type` step would drag its typed value into a `click`, and `click` -> `navigate`
* would leave a navigate with no url. What was wrong is that it happened in silence.
*/
const actionChangedFromRecorded = ref(false);
const actionComputed = computed({
get: () => props.step.action,
set: (v: BrowserStep["action"]) => {
if (v !== props.step.action && props.step.wire) actionChangedFromRecorded.value = true;
update({ action: v });
emit("action-edited");
},
});
const nameComputed = computed({
get: () => props.step.name ?? "",
set: (v: string) => update({ name: 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 }),
});
// Timeout guard rails (spec P1.1.4, P1.1.5)
// The recorder no longer stamps a timeout, so this field renders empty which
// reads as "no timeout" and invites authors to fill it in needlessly. Show the
// default the runner will actually apply, so an author can see what they would
// be overriding before they override it.
const timeoutDefault = computed(() => defaultTimeoutFor(props.step.action));
// Lowering below the category default is permitted it is the author's call
// but a step timeout shorter than the application's real response time is
// precisely the condition that produced the observed production failures.
// Advisory only: it must never block saving.
const timeoutBelowDefault = computed(() => {
const explicit = props.step.timeout;
return explicit !== undefined && explicit < timeoutDefault.value;
});
// Version-2 blocks
// There is one targeting UI. The v1 Selector-type + Selector pair used to render
// when a step carried no bundle, which meant a hand-added step and a recorded one
// presented two unrelated editors (SE-7) and a hand-added step that named its
// element the v1 way flipped the whole journey to steps_version 1, because
// isV2Journey reads `locator`, not `selector` (SE-18). No v1 journeys exist, so
// the fork served no case and is gone. See `showTarget` for the render condition.
function updateLocator(locator: StepLocator) {
update({ locator });
}
function updateAssertion(assertion: StepAssertion) {
update({ assertion });
}
const optionalComputed = computed({
get: () => !!props.step.optional,
set: (v: boolean) => update({ optional: v }),
});
const alwaysRunComputed = computed({
get: () => !!props.step.alwaysRun,
set: (v: boolean) => update({ alwaysRun: v }),
});
// Settle block (spec P3.3, P4)
// What the recording observed is evidence and stays read-only. Two fields on it
// are author decisions, though, and had no control at all: marking a response
// required (P4.1.5 the recorder always emits `false`, because deciding a run
// is meaningless without a given call is a judgement about the application) and
// the settle budget (P3.4.3 the wait-lift writes one and the author could
// neither see nor change the number it picked).
const settleNavigationLine = computed(() => {
const nav = props.step.settle?.navigation;
return nav ? t("synthetics.journey.settleNavigation", { pattern: nav.url_pattern }) : "";
});
const settleResponses = computed(() => props.step.settle?.responses ?? []);
const settleObservedLine = computed(() => {
const ms = props.step.settle?.observed_duration_ms;
return ms === undefined
? ""
: t("synthetics.journey.settleObserved", { seconds: (ms / 1000).toFixed(1) });
});
/**
* Did the recording observe anything to wait for?
*
* Gates the read-only evidence lines only. It must NOT gate the settle group as a
* whole: the budget input lives in there and is the only way to create a budget,
* so gating on "has settle data" meant a hand-added step could never be given one
* the condition was self-fulfilling (SE-16).
*/
const hasRecordedSettle = computed(
() =>
!!settleNavigationLine.value || settleResponses.value.length > 0 || !!settleObservedLine.value,
);
function settleResponseLabel(response: SettleResponse): string {
return t("synthetics.journey.settleResponse", {
method: response.method ?? "",
pattern: response.url_pattern,
});
}
function setResponseRequired(index: number, next: CheckboxModelValue) {
const settle = props.step.settle;
if (!settle?.responses) return;
// OCheckbox also models "indeterminate"; a settle signal is required or it is
// not, so only an explicit `true` counts as required.
const required = next === true;
update({
settle: {
...settle,
responses: settle.responses.map((r, i) => (i === index ? { ...r, required } : r)),
},
});
}
const settleBudgetComputed = computed({
get: () => String(props.step.settle?.budget_ms ?? ""),
set: (v: string) => {
const { budget_ms: _dropped, ...rest } = props.step.settle ?? {};
update({ settle: v ? { ...rest, budget_ms: Number(v) } : rest });
},
});
// Advisory, like the timeout warning: the server enforces the range, so a value
// outside it must be visible here rather than surfacing as a save failure.
const settleBudgetOutOfRange = computed(() => {
const budget = props.step.settle?.budget_ms;
if (budget === undefined) return false;
return budget < MIN_SETTLE_BUDGET_MS || budget > MAX_SETTLE_BUDGET_MS;
});
// Field layout (spec SE-5)
// Two tiers, not three groups. What the step does is the step action, name,
// target, value, assertion so it is plain always-visible markup with no
// disclosure of its own. Everything a recording or a runner default already
// answers correctly page settling, the timeout, failure behaviour sits behind
// one `Advanced` collapsible.
//
// The earlier shape had three peer collapsibles. That charged the same price for
// the step's identity as for its tuning: an author editing a step had to open a
// group to reach the fields the step cannot function without, while a `default-open`
// collapsible around always-visible content is a control that does nothing but
// take a click away.
//
// One collapsible is also the only separation device here. No cards, no rules, no
// sub-sections: each field carries its own label, so a second grouping vocabulary
// on top of them would say the same thing twice.
//
// Every field that can currently carry a validation error action, name, target,
// value, assertion expected is outside the collapsible, so an error can never be
// collapsed out of view. When SE-2/SE-19 introduce settle-budget or timeout errors,
// `Advanced` must become controlled so an error force-opens it.
const seconds = (ms: number) => Number((ms / 1000).toFixed(1));
// Plain-language summary (spec SE-6)
// Every comparable tool leads a step editor with a sentence describing the step in
// the author's words rather than the tool's. Composed entirely from values already
// on screen, so it can never disagree with the fields below it.
/** What the run will actually target: the pin if there is one, else the primary. */
const effectiveTarget = computed(() => {
const locator = props.step.locator;
return locator?.user_override?.value ?? locator?.candidates?.[0]?.value ?? "";
});
const summary = computed(() => {
const action = ACTION_LABELS[props.step.action] ?? props.step.action;
const target = showTarget.value ? effectiveTarget.value : (props.step.value ?? "");
const sentence = target ? t("synthetics.journey.summaryWithTarget", { action, target }) : action;
const effectiveTimeout = props.step.timeout ?? timeoutDefault.value;
return t("synthetics.journey.summaryWaitingUpTo", {
sentence,
seconds: seconds(effectiveTimeout),
});
});
// Timeout helper (spec SE-9 / D8, SE-20)
// The placeholder stays P1.1.5 mandates it, and T1-13 asserts it but a
// placeholder alone reads as "empty" to the audience it was meant to inform. This
// line is additive: it says what blank means and what the bounds are.
//
// On navigate and assert the category default (60 s) EQUALS the server maximum, so
// the field can only ever shorten the timeout. Saying so up front is what stops the
// below-default warning reading as a malfunction (SE-20).
const timeoutIsCeiling = computed(() => timeoutDefault.value >= MAX_STEP_TIMEOUT_MS);
const timeoutHelp = computed(() =>
timeoutIsCeiling.value
? t("synthetics.journey.timeoutHelpNavAssert", { seconds: seconds(timeoutDefault.value) })
: t("synthetics.journey.timeoutHelpInteraction", {
seconds: seconds(timeoutDefault.value),
max: seconds(MAX_STEP_TIMEOUT_MS),
}),
);
/**
* Everything inside `Advanced` that is not a default, named.
*
* A collapsed section must advertise what it holds or collapsing hides state. Empty
* when the step carries nothing but defaults, which is also the signal for whether
* the section opens itself see `advancedCaption`.
*/
const advancedChanges = computed(() => {
const parts: string[] = [];
if (hasRecordedSettle.value) parts.push(t("synthetics.journey.captionRecorded"));
const budget = props.step.settle?.budget_ms;
if (budget !== undefined)
parts.push(t("synthetics.journey.captionBudget", { seconds: seconds(budget) }));
if (props.step.timeout !== undefined)
parts.push(t("synthetics.journey.captionTimeout", { seconds: seconds(props.step.timeout) }));
if (props.step.optional) parts.push(t("synthetics.journey.captionOptional"));
if (props.step.alwaysRun) parts.push(t("synthetics.journey.captionAlwaysRun"));
return parts.join(" · ");
});
/**
* Names the non-default values when there are any, and what the section is for
* when there are not matching how the alert form captions its own Advanced step
* ("Context variables, description, and row template"). A bare label would leave
* an author guessing whether anything in here applies to their step.
*/
const advancedCaption = computed(
() => advancedChanges.value || t("synthetics.journey.groupAdvancedCaption"),
);
</script>
<template>
<div class="flex w-full flex-col gap-2" data-test="synthetics-journey-step-editor">
<!-- What this step will do, in the author's words rather than the tool's.
Composed from the same values the fields below show, so the two cannot
disagree. -->
<p class="text-text-body m-0 text-sm" data-test="synthetics-journey-step-summary">
{{ summary }}
</p>
<!-- What this step does no disclosure of its own. These are the fields the
step cannot function without, and every field that can carry a validation
error is here, so an error can never be collapsed out of view. -->
<div class="flex w-full flex-col gap-3" data-test="synthetics-journey-step-group-does">
<div class="flex w-full gap-2">
<OSelect
v-model="actionComputed"
:label="t('synthetics.journey.actionLabel')"
:options="actionOptions"
class="basis-1/3"
:error="!!actionErrorMessage"
:error-message="actionErrorMessage ?? ''"
data-test="synthetics-journey-step-action-select"
/>
<OInput
v-model="nameComputed"
:label="t('synthetics.journey.stepNameLabel')"
:placeholder="t('synthetics.journey.stepNamePurposePlaceholder')"
:required="true"
:error="!!nameErrorMessage"
:error-message="nameErrorMessage ?? ''"
class="basis-2/3"
data-test="synthetics-journey-step-name-input"
/>
</div>
<!-- The discard is right; doing it silently was not (D9). -->
<p
v-if="actionChangedFromRecorded"
class="text-text-secondary m-0 flex items-start gap-1 text-xs"
data-test="synthetics-journey-step-action-changed-notice"
>
<OIcon name="info-outline" size="xs" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{ t("synthetics.journey.actionChangedNotice") }}</span>
</p>
<!-- Target the locator bundle is the only way a step names its element.
`stepNeedsTarget` is the same rule the save-time validator uses, so the
block appears exactly when a target is required. -->
<BrowserJourneyLocator
v-if="showTarget"
:locator="effectiveLocator"
@update:locator="updateLocator"
/>
<!-- Value (action-specific label) -->
<OInput
v-if="showValue"
v-model="valueComputed"
:label="valueLabel"
:placeholder="valueLabel"
class="w-full"
:error="!!valueErrorMessage"
:error-message="valueErrorMessage ?? ''"
data-test="synthetics-journey-step-value-input"
>
<template v-if="valueTooltip" #tooltip>
<OTooltip :content="valueTooltip" />
</template>
</OInput>
<!-- Typed assertion what this step actually verifies -->
<BrowserJourneyAssertion
v-if="step.action === 'assert'"
:assertion="step.assertion"
:expected-error-message="expectedErrorMessage"
@update:assertion="updateAssertion"
/>
</div>
<!-- Advanced settling, timeout and failure behaviour, all of which a
recording or a runner default already answers. Rendered unconditionally:
the budget input is the only way to create a budget, so gating on "has
settle data" made it unreachable on a hand-added step (SE-16). Only the
recorded evidence lines are conditional.
Opens itself when the step carries a non-default, so nothing an author set
is hidden from them. -->
<OCollapsible
:label="t('synthetics.journey.groupAdvancedLabel')"
:caption="advancedCaption"
:default-open="!!advancedChanges"
data-test="synthetics-journey-step-group-advanced"
>
<div class="flex w-full flex-col gap-3 pt-2">
<!-- Waiting: what the recording observed, then the two numbers that bound
the wait. Adjacent because they answer one question. -->
<div class="flex w-full flex-col gap-2" data-test="synthetics-journey-step-settle">
<template v-if="hasRecordedSettle">
<span class="text-text-secondary text-xs">{{
t("synthetics.journey.settleLabel")
}}</span>
<p v-if="settleNavigationLine" class="text-text-secondary m-0 font-mono text-xs">
{{ settleNavigationLine }}
</p>
<div
v-for="(response, i) in settleResponses"
:key="`${response.url_pattern}-${i}`"
class="flex w-full items-center gap-2"
>
<OCheckbox
:model-value="!!response.required"
size="xs"
:label="t('synthetics.journey.settleRequiredLabel')"
:data-test="`synthetics-journey-step-settle-required-${i}`"
@update:model-value="setResponseRequired(i, $event)"
/>
<span class="text-text-secondary min-w-0 truncate font-mono text-xs">
{{ settleResponseLabel(response) }}
</span>
</div>
<p v-if="settleObservedLine" class="text-text-secondary m-0 font-mono text-xs">
{{ settleObservedLine }}
</p>
</template>
<OInput
v-model="settleBudgetComputed"
:label="t('synthetics.journey.settleBudgetLabel')"
:placeholder="String(DEFAULT_SETTLE_BUDGET_MS)"
type="number"
class="w-full"
data-test="synthetics-journey-step-settle-budget-input"
/>
<p
v-if="settleBudgetOutOfRange"
class="text-status-warning-text m-0 flex items-start gap-1 text-xs"
data-test="synthetics-journey-step-settle-budget-warning"
>
<OIcon name="warning" size="xs" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{
t("synthetics.journey.settleBudgetRangeWarning", {
min: MIN_SETTLE_BUDGET_MS,
max: MAX_SETTLE_BUDGET_MS,
})
}}</span>
</p>
</div>
<OInput
v-model="timeoutComputed"
:label="t('synthetics.journey.timeoutLabel')"
:placeholder="String(timeoutDefault)"
type="number"
class="w-full"
data-test="synthetics-journey-step-timeout-input"
/>
<!-- Additive to the placeholder, which P1.1.5 mandates: says what blank
means, and on navigate/assert that the default is also the ceiling. -->
<p class="text-text-secondary m-0 text-xs" data-test="synthetics-journey-step-timeout-help">
{{ timeoutHelp }}
</p>
<p
v-if="timeoutBelowDefault"
class="text-status-warning-text m-0 flex items-start gap-1 text-xs"
data-test="synthetics-journey-step-timeout-warning"
>
<OIcon name="warning" size="xs" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{
t("synthetics.journey.timeoutBelowDefaultWarning", { default: timeoutDefault })
}}</span>
</p>
<!-- Both flags are fully implemented in the probe with semantics the labels
omit the `skipped` result status, the cleanup pass, the neutral
verdict, and that `always_run` only reaches steps AFTER the failure.
Both-set is legitimate (a best-effort logout), so this explains rather
than prevents (D11). -->
<div class="flex items-center gap-1">
<OCheckbox
v-model="optionalComputed"
:label="t('synthetics.journey.optionalLabel')"
data-test="synthetics-journey-step-optional-checkbox"
/>
<OTooltip :content="t('synthetics.journey.optionalHelp')">
<OIcon
name="info-outline"
size="xs"
class="text-text-secondary"
data-test="synthetics-journey-step-optional-help"
aria-hidden="true"
/>
</OTooltip>
</div>
<div class="flex items-center gap-1">
<OCheckbox
v-model="alwaysRunComputed"
:label="t('synthetics.journey.alwaysRunLabel')"
data-test="synthetics-journey-step-always-run-checkbox"
/>
<OTooltip :content="t('synthetics.journey.alwaysRunHelp')">
<OIcon
name="info-outline"
size="xs"
class="text-text-secondary"
data-test="synthetics-journey-step-always-run-help"
aria-hidden="true"
/>
</OTooltip>
</div>
</div>
</OCollapsible>
</div>
</template>

View File

@ -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<string, unknown> },
});
const test = (name: string) => `[data-test="${name}"]`;
function render(result: Partial<StepReplayResult> = {}, 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("13");
});
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();
});
});

View File

@ -0,0 +1,151 @@
// Copyright 2026 OpenObserve Inc.
<script setup lang="ts">
/**
* What the runner saw on a step that failed during replay.
*
* This card used to live in `BrowserJourneyStep.vue`. When that component was
* replaced by `JourneySteps` + OTable the card was not carried across, so a failed
* replay showed only a red dot and a journey-level banner: no message, no exit
* reason, no duration, no failed selector. The evidence was already being computed
* and thrown away (SE-4).
*
* It also renders the player's fidelity notes. X-8.2 requires the preview to say
* per step what it cannot reproduce ordered candidate fallback, settle, some
* assertion kinds, an author-set timeout below 60 s, uploads, retired actions
* *"A step the player skipped MUST NOT render as a pass. Silent divergence is the
* failure mode this whole section exists to prevent."* The extension emits them;
* until now nothing displayed them.
*/
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import type { StepReplayResult } from "@/types/synthetics";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
const props = defineProps<{
result: StepReplayResult;
/** Position in the journey, so "re-run to here" can name what it will run. */
stepNumber?: number;
}>();
const emit = defineEmits<{ "retry-replay": [] }>();
const { t } = useI18n();
const se = computed(() => props.result.structuredError);
/** Map structuredError.name to an icon. */
const errorIconName = computed<string>(() => {
switch (se.value?.name) {
case "TimeoutError":
return "timer-off";
case "TargetClosedError":
return "visibility-off";
default:
return "error";
}
});
/** Map structuredError.name to a human label. */
const errorLabel = computed<string>(() => {
switch (se.value?.name) {
case "TimeoutError":
return t("synthetics.stepErrors.timeout");
case "TargetClosedError":
return t("synthetics.stepErrors.tabClosed");
default:
return t("synthetics.stepErrors.default");
}
});
/** Exit reason tag (e.g. "hit timeout", "tab closed"). */
const exitReasonTag = computed<string>(() => {
const name = se.value?.name;
if (name === "TimeoutError") return t("synthetics.stepErrors.hitTimeout");
if (name === "TargetClosedError") return t("synthetics.stepErrors.tabClosedReason");
return t("synthetics.stepErrors.exitReason");
});
const durationFormatted = computed(() => `${((props.result.durationMs ?? 0) / 1000).toFixed(1)} s`);
/** X-8.2 divergence notes, if the player reported any for this step. */
const fidelityNotes = computed(() => props.result.fidelity?.notes ?? []);
</script>
<template>
<div
class="border-badge-error-ol-border/30 rounded-default overflow-hidden border"
data-test="synthetics-journey-step-error-card"
>
<div class="flex items-center gap-2 bg-[var(--color-badge-error-soft-bg)] px-3 py-2">
<OIcon :name="errorIconName" size="sm" class="text-status-error-text" aria-hidden="true" />
<span class="text-text-heading flex-1 text-xs font-semibold">{{ errorLabel }}</span>
<span class="text-text-secondary font-mono text-xs">
{{ exitReasonTag }} · {{ durationFormatted }}
</span>
</div>
<div class="px-3 py-3">
<p class="text-text-body m-0 text-xs" data-test="synthetics-journey-step-error-message">
{{ se?.message || result.error }}
</p>
</div>
<!-- The element the runner could not act on, and how long it waited. -->
<div v-if="se?.selector" class="flex gap-4 px-3 pb-3">
<div class="flex flex-col gap-1">
<span class="text-2xs text-text-label font-medium">
{{ t("synthetics.stepErrors.selectorTestId") }}
</span>
<span
class="text-status-error-text font-mono text-xs"
data-test="synthetics-journey-step-error-selector"
>{{ se.selector }}</span
>
</div>
<div class="flex flex-col gap-1">
<span class="text-2xs text-text-label font-medium">
{{ t("synthetics.stepErrors.waited") }}
</span>
<span class="text-text-secondary font-mono text-xs">
{{ durationFormatted }} · {{ exitReasonTag }}
</span>
</div>
</div>
<!-- X-8.2: what the preview could not reproduce. Silence here is the failure
mode the requirement exists to prevent. -->
<div v-if="fidelityNotes.length" class="px-3 pb-3" data-test="synthetics-journey-step-fidelity">
<span class="text-2xs text-text-label font-medium">
{{ t("synthetics.journey.fidelityLabel") }}
</span>
<ul class="m-0 mt-1 flex list-none flex-col gap-1 p-0">
<li
v-for="note in fidelityNotes"
:key="note"
class="text-text-secondary flex items-start gap-1 text-xs"
>
<OIcon name="info-outline" size="xs" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{ note }}</span>
</li>
</ul>
</div>
<div class="flex items-center gap-2 px-3 pb-3">
<OButton
variant="outline"
size="xs"
icon-left="replay"
data-test="synthetics-journey-error-retry-btn"
@click="emit('retry-replay')"
>
{{
stepNumber
? t("synthetics.journey.reRunToHere", { step: stepNumber })
: t("synthetics.journey.reRun")
}}
</OButton>
</div>
</div>
</template>

View File

@ -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: '<button v-bind="$attrs" @click="$emit(\'click\')"><slot /></button>',
};
const OIconStub = {
template: '<i :data-icon="$attrs.name" />',
};
const OSpinnerStub = {
template: '<div class="spinner-stub" />',
};
const BrowserJourneyStepStub = {
props: [
"step",
"index",
"expanded",
"selected",
"replayDotState",
"replayLocked",
"replayResult",
],
emits: [
"update:step",
"update:expanded",
"delete",
"duplicate",
"insert-below",
"toggle-select",
"retry-replay",
],
template:
'<div class="journey-step-stub" :data-step-action="step.action" :data-step-name="step.name" />',
};
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();
});
});
});

View File

@ -1,193 +0,0 @@
<script setup lang="ts">
// Copyright 2026 OpenObserve Inc.
import { onMounted, onUnmounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { BrowserStep } from "@/types/synthetics";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import BrowserJourneyStep from "./BrowserJourneyStep.vue";
const { t } = useI18n();
const props = defineProps<{
startUrl: string;
}>();
const emit = defineEmits<{
done: [steps: BrowserStep[]];
cancel: [];
}>();
const capturedSteps = ref<BrowserStep[]>([]);
// template-invokable id generator (script scope resolves the global crypto)
const genId = () => crypto.randomUUID();
const recordingSeconds = ref(0);
const currentUrl = ref(props.startUrl);
let timerInterval: ReturnType<typeof setInterval> | null = null;
let stepTimeouts: ReturnType<typeof setTimeout>[] = [];
function formatTime(seconds: number): string {
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
const ss = String(seconds % 60).padStart(2, "0");
return `${mm}:${ss}`;
}
function stopRecording() {
if (timerInterval !== null) {
clearInterval(timerInterval);
timerInterval = null;
}
emit("done", capturedSteps.value);
}
onMounted(() => {
// Start timer
timerInterval = setInterval(() => {
recordingSeconds.value++;
}, 1000);
// Simulate captured steps for demo purposes (real recorder integration is future work)
stepTimeouts.push(
setTimeout(() => {
capturedSteps.value.push({
id: crypto.randomUUID(),
action: "navigate",
name: "Open start URL",
value: props.startUrl,
timeout: 30000,
code: "",
});
}, 500),
setTimeout(() => {
capturedSteps.value.push({
id: crypto.randomUUID(),
action: "click",
name: "Click login button",
selector: "#login-btn",
selectorType: "CSS",
timeout: 30000,
code: "",
});
}, 2000),
setTimeout(() => {
capturedSteps.value.push({
id: crypto.randomUUID(),
action: "type",
name: "Enter username",
selector: "#username",
selectorType: "CSS",
value: "user@example.com",
timeout: 30000,
code: "",
});
}, 4000),
);
});
onUnmounted(() => {
if (timerInterval !== null) clearInterval(timerInterval);
stepTimeouts.forEach(clearTimeout);
});
</script>
<template>
<div class="flex min-h-screen flex-col">
<!-- Recording banner -->
<div
class="bg-status-error-bg border-border-default flex items-center gap-3 border-b px-4 py-2"
>
<!-- Red dot + timer -->
<span class="flex items-center gap-1.5">
<span
class="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-status-error-text)]"
aria-hidden="true"
/>
<span class="text-status-error-text text-sm font-semibold">{{
t("synthetics.journey.recording")
}}</span>
<span class="text-text-body font-mono text-sm">{{ formatTime(recordingSeconds) }}</span>
</span>
<!-- Current URL -->
<span class="text-text-secondary flex min-w-0 flex-1 items-center gap-1 truncate text-xs">
<OIcon name="shield" size="sm" class="shrink-0" aria-hidden="true" />
<span class="truncate">{{ currentUrl }}</span>
</span>
<!-- Actions -->
<div class="flex shrink-0 items-center gap-2">
<OButton variant="ghost" size="sm" @click="emit('cancel')">{{
t("synthetics.journey.cancel")
}}</OButton>
<OButton
variant="primary"
size="sm"
data-test="synthetics-record-stop-btn"
@click="stopRecording"
>
{{ t("synthetics.journey.stopAndReview") }}
</OButton>
</div>
</div>
<!-- Info banner -->
<div class="border-border-default bg-surface-base flex items-center gap-2 border-b px-4 py-2">
<OIcon name="open-in-new" size="sm" class="text-text-muted" aria-hidden="true" />
<span class="text-text-secondary flex-1 text-xs">{{
t("synthetics.journey.recordingIncognitoInfo")
}}</span>
<OButton variant="outline" size="sm">{{ t("synthetics.journey.showWindow") }}</OButton>
</div>
<!-- Captured steps -->
<div class="flex-1 overflow-y-auto p-4">
<div class="mb-3 flex items-center gap-2">
<h3 class="text-text-heading m-0 text-base font-semibold">
{{ t("synthetics.journey.journeyHeading") }}
</h3>
<span
class="bg-status-error-bg text-status-error-text flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
>
<span
class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--color-status-error-text)]"
aria-hidden="true"
/>
{{ t("synthetics.journey.capturingLive") }}
</span>
<span class="text-text-muted text-sm">{{
t("synthetics.journey.stepCount", { count: capturedSteps.length })
}}</span>
</div>
<!-- Live step list -->
<div v-if="capturedSteps.length > 0" class="flex flex-col gap-1">
<BrowserJourneyStep
v-for="(step, index) in capturedSteps"
:key="step.id"
:step="step"
:index="index"
:expanded="false"
@update:step="capturedSteps[index] = $event"
@update:expanded="() => {}"
@delete="capturedSteps.splice(index, 1)"
@duplicate="capturedSteps.splice(index + 1, 0, { ...step, id: genId() })"
@insert-below="() => {}"
/>
</div>
<!-- Waiting for first step -->
<div v-else class="flex flex-col items-center justify-center gap-3 py-16 text-center">
<OIcon
name="fiber-manual-record"
size="xl"
class="text-text-muted animate-pulse"
aria-hidden="true"
/>
<p class="text-text-secondary m-0 text-sm">
{{ t("synthetics.journey.waitingForActions") }}
</p>
</div>
</div>
</div>
</template>

View File

@ -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);
});
});
});

View File

@ -0,0 +1,82 @@
<script setup lang="ts">
// Copyright 2026 OpenObserve Inc.
/**
* "This recording found no test attributes" the misconfiguration that is
* otherwise silent.
*
* The recorder selects on ONE configured DOM attribute. Playwright defaults to
* `data-testid`; O2's own frontend uses `data-test`; a customer may use
* `data-qa`, `data-cy` or `data-automation-id`. When the configured attribute is
* not the one the application uses, upstream's generator produces NO
* `test_attribute` candidates at all and every step quietly degrades to
* role/text/css the least stable ranks with no error anywhere.
*
* The signal is unambiguous and cheap: a journey against a page that has test
* attributes will produce at least one `test_attribute` candidate. Zero across
* an entire recording means the recorder was looking for the wrong attribute, or
* the application genuinely has none and both are worth saying out loud rather
* than discovering months later when a locator rots.
*
* Deliberately not an error: a page really may have no test attributes, and the
* journey still works. This tells the author what they are trading away.
*/
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { BrowserStep } from "@/types/synthetics";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OButton from "@/lib/core/Button/OButton.vue";
const props = defineProps<{
steps: BrowserStep[];
/** The attribute the recording actually used, for the message. */
testIdAttr: string;
}>();
const { t } = useI18n();
const dismissed = ref(false);
/** Steps that identify an element at all — a navigate has nothing to find. */
const locatorSteps = computed(() =>
props.steps.filter((s) => (s.locator?.candidates?.length ?? 0) > 0),
);
const hasTestAttribute = computed(() =>
locatorSteps.value.some((s) => s.locator!.candidates.some((c) => c.kind === "test_attribute")),
);
const show = computed(
() => !dismissed.value && locatorSteps.value.length > 0 && !hasTestAttribute.value,
);
</script>
<template>
<div
v-if="show"
class="rounded-default border-border-default mb-3 flex items-start gap-2 border px-3 py-2"
data-test="synthetics-journey-testid-misconfigured"
>
<OIcon
name="warning"
size="sm"
class="text-status-warning-text mt-0.5 shrink-0"
aria-hidden="true"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-text-body text-sm font-medium">
{{ t("synthetics.journey.testIdMissingTitle") }}
</span>
<span class="text-text-secondary text-xs">
{{ t("synthetics.journey.testIdMissingDescription", { attr: testIdAttr }) }}
</span>
</div>
<OButton
variant="ghost"
size="xs"
data-test="synthetics-journey-testid-misconfigured-dismiss"
@click="dismissed = true"
>
{{ t("synthetics.journey.testIdMissingDismiss") }}
</OButton>
</div>
</template>

View File

@ -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<string, unknown> },
});
function step(overrides: Partial<BrowserStep> = {}): 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);
});
});

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
// Copyright 2026 OpenObserve Inc.
/**
* The in-place upgrade from a version-1 journey to version 2.
*
* This exists because saving is now refused for a journey containing a retired
* action (spec Q-10): hard sleeps and unreplayable steps are the single largest
* source of the flakiness the schema change exists to remove. Refusing without
* offering the remedy would just be an error message, so the remedy is offered
* right where the refusal happens (Q-10.b).
*
* The lift is previewed before it is applied (P2.6.3). Dropping a step or
* removing a sleep is a real behaviour change, and an author should read it
* before committing rather than discover it from a diff which is exactly why
* the lift is a pure function that can be run to produce a preview without a
* round trip (D-11).
*/
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { BrowserStep } from "@/types/synthetics";
import { liftJourney } from "@/utils/synthetics/liftJourney";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
const props = defineProps<{ steps: BrowserStep[] }>();
const emit = defineEmits<{ upgrade: [value: BrowserStep[]] }>();
const { t } = useI18n();
const preview = computed(() => liftJourney(props.steps));
const needsUpgrade = computed(() => !preview.value.noop);
const showDetail = ref(false);
function apply() {
emit("upgrade", preview.value.steps);
showDetail.value = false;
}
</script>
<template>
<div
v-if="needsUpgrade"
class="rounded-surface bg-warning-50 mb-3 flex flex-col gap-2 border border-[var(--color-warning-300)] px-3 py-3"
role="status"
data-test="synthetics-journey-upgrade-banner"
>
<div class="flex items-center gap-2">
<OIcon name="arrow-upward" size="sm" class="text-warning-600" aria-hidden="true" />
<span class="text-text-heading text-sm font-semibold">
{{ t("synthetics.journey.upgradeTitle") }}
</span>
<OBadge variant="default" size="sm">
{{ t("synthetics.journey.upgradeChangeCount", { count: preview.changes.length }) }}
</OBadge>
</div>
<p class="text-text-secondary m-0 text-xs">
{{ t("synthetics.journey.upgradeDescription") }}
</p>
<ul
v-if="showDetail"
class="text-text-body m-0 flex list-disc flex-col gap-1 pl-4 text-xs"
data-test="synthetics-journey-upgrade-changes"
>
<li v-for="(change, i) in preview.changes" :key="`${change.stepId}-${i}`">
<span class="font-semibold">{{ change.stepName }}</span>
{{ change.detail }}
</li>
</ul>
<div class="flex items-center gap-2">
<OButton
variant="primary"
size="sm"
data-test="synthetics-journey-upgrade-apply-btn"
@click="apply"
>
{{ t("synthetics.journey.upgradeApply") }}
</OButton>
<OButton
variant="ghost"
size="sm"
data-test="synthetics-journey-upgrade-preview-btn"
@click="showDetail = !showDetail"
>
{{
showDetail ? t("synthetics.journey.upgradeHide") : t("synthetics.journey.upgradePreview")
}}
</OButton>
</div>
</div>
</template>

View File

@ -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<string, unknown> },
});
function step(overrides: Partial<BrowserStep> = {}): 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);
});
});

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
// Copyright 2026 OpenObserve Inc.
/**
* The nudge for a journey that verifies nothing.
*
* A journey with no assertion can click its way through a broken application and
* still report a pass it proves the steps can be performed, not that the
* application works. That is the failure mode where a monitor quietly stops
* noticing anything, which is worse than a monitor that is obviously broken.
*
* It is a warning and not an error (spec P5.2.4). A monitor that only navigates
* still proves the site answers, so refusing to save one would be wrong; but the
* author should have to decline the assertion rather than never be offered it.
*
* Dismissible on purpose: an author who has decided is not told twice.
*/
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { BrowserStep } from "@/types/synthetics";
import { getUUIDv7 } from "@/utils/zincutils";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
const props = defineProps<{ steps: BrowserStep[] }>();
const emit = defineEmits<{ "add-assertion": [value: BrowserStep] }>();
const { t } = useI18n();
const dismissed = ref(false);
const hasAssertion = computed(() => props.steps.some((s) => s.action === "assert"));
const show = computed(() => props.steps.length > 0 && !hasAssertion.value && !dismissed.value);
/**
* The suggested assertion is the one whose absence causes the production
* failures this design exists to fix: something on the post-login page is
* visible. It is added empty rather than guessed at the recorder cannot know
* what "correct" means for an application, and no vendor pretends otherwise
* (P5.2.1).
*/
function addAssertion() {
emit("add-assertion", {
id: getUUIDv7(true),
action: "assert",
name: t("synthetics.journey.assertionSuggestedName"),
assertion: { kind: "element_visible" },
code: "",
});
dismissed.value = true;
}
</script>
<template>
<div
v-if="show"
class="rounded-surface bg-warning-50 mb-3 flex flex-col gap-2 border border-[var(--color-warning-300)] px-3 py-3"
role="status"
data-test="synthetics-journey-zero-assertion-notice"
>
<div class="flex items-center gap-2">
<OIcon name="fact-check" size="sm" class="text-warning-600" aria-hidden="true" />
<span class="text-text-heading text-sm font-semibold">
{{ t("synthetics.journey.zeroAssertionTitle") }}
</span>
</div>
<p class="text-text-secondary m-0 text-xs">
{{ t("synthetics.journey.zeroAssertionDescription") }}
</p>
<div class="flex items-center gap-2">
<OButton
variant="primary"
size="sm"
data-test="synthetics-journey-add-assertion-btn"
@click="addAssertion"
>
{{ t("synthetics.journey.zeroAssertionAdd") }}
</OButton>
<OButton
variant="ghost"
size="sm"
data-test="synthetics-journey-zero-assertion-dismiss-btn"
@click="dismissed = true"
>
{{ t("synthetics.journey.zeroAssertionDismiss") }}
</OButton>
</div>
</div>
</template>

View File

@ -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<string, unknown> = {}) =>
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");
});
});

View File

@ -0,0 +1,352 @@
<!-- 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 <http://www.gnu.org/licenses/>. -->
<script setup lang="ts">
/**
* The evidence panel what the browser did while the journey ran.
*
* Reads the BUNDLE, not `evidence_by_step`. That field is an anomaly index:
* `summarise()` emits a row only 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
* carries an empty index while the bundle holds every event so on the most
* common failure (a locator that never matched) the index says "nothing to
* report" and the bundle says what the page was actually doing.
*
* Per attempt: each attempt uploads its own bundle, attempt 0 at the bare key
* and retries at `attempt-N-`. Showing one under another's label is a real
* error, not a cosmetic one.
*/
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
foldEvidenceBundle,
parseEvidenceNdjson,
type EvidenceEvent,
} from "@/composables/synthetics/syntheticResultsSchema";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
const props = defineProps<{
/** Object-storage key of the selected attempt's bundle. Null when none exists. */
evidenceKey: string | null;
/** Resolves a key to a fetchable URL. Already presigned for every attempt. */
resolveUrl: (key: string) => string;
/** step_id -> definition, for naming the step on each row. */
stepDefs: Map<string, { name: string; selector: string | null }>;
/** `evidence_truncated` from the record. */
recordTruncated?: boolean;
/** Whether capture is switched off for this check, vs merely not kept. */
captureOff?: boolean;
/** Whether the run passed — evidence is retained for failures by default. */
runPassed?: boolean;
}>();
const { t } = useI18n();
type Filter = "all" | "consoleErrors" | "pageErrors" | "requestsFailed" | "nonNon2xx";
const loading = ref(false);
const loadError = ref<string | null>(null);
const events = ref<EvidenceEvent[]>([]);
const fetched = ref(false);
const filter = ref<Filter>("all");
const firstPartyOnly = ref(false);
const expanded = ref(new Set<number>());
/**
* Fetched on demand, not with the record: the bundle runs to 256 KB at the cap
* and most users never open this tab.
*/
async function load() {
if (!props.evidenceKey) return;
loading.value = true;
loadError.value = null;
try {
const res = await fetch(props.resolveUrl(props.evidenceKey));
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
events.value = parseEvidenceNdjson(await res.text());
fetched.value = true;
} catch (e: any) {
// Never an empty list on failure "the fetch broke" and "the run was quiet"
// are different findings and must not render the same.
loadError.value = e?.message ?? String(e);
events.value = [];
} finally {
loading.value = false;
}
}
// Switching attempts changes the key; refetch that attempt's own bundle.
watch(
() => props.evidenceKey,
() => {
fetched.value = false;
events.value = [];
loadError.value = null;
if (props.evidenceKey) load();
},
{ immediate: true },
);
const bundle = computed(() =>
foldEvidenceBundle(events.value, props.stepDefs, props.recordTruncated ?? false),
);
/**
* Grouped by kind, so the labels come from one place.
*
* Severity order, not volume order: page errors before a wall of 200s.
*/
const GROUP_LABEL: Record<string, string> = {
pageErrors: "synthetics.evidence.groupPageErrors",
requestsFailed: "synthetics.evidence.groupFailedReq",
console: "synthetics.evidence.groupConsole",
network: "synthetics.evidence.groupNetwork",
};
function matches(e: EvidenceEvent): boolean {
if (firstPartyOnly.value && !e.firstParty) return false;
switch (filter.value) {
case "consoleErrors":
return e.kind === "console" && e.level === "error";
case "pageErrors":
return e.kind === "pageerror" || e.kind === "crash";
case "requestsFailed":
return e.kind === "requestfailed";
case "nonNon2xx":
return e.kind === "response" && (e.status ?? 0) >= 400;
default:
return true;
}
}
/** Groups after filtering. An emptied group disappears unlike a zero-count
* chip, an empty section header carries no information. */
const visibleGroups = computed(() =>
bundle.value.groups
.map((g) => ({ ...g, events: g.events.filter(matches) }))
.filter((g) => g.events.length > 0),
);
const chips = computed(() => {
const c = bundle.value.counts;
return [
{ key: "all" as Filter, label: t("synthetics.evidence.filterAll"), count: c.all },
{
key: "consoleErrors" as Filter,
label: t("synthetics.evidence.filterConsole"),
count: c.consoleErrors,
},
{
key: "pageErrors" as Filter,
label: t("synthetics.evidence.filterPageErrors"),
count: c.pageErrors,
},
{
key: "nonNon2xx" as Filter,
label: t("synthetics.evidence.filterNon2xx"),
count: c.nonNon2xx,
},
{
key: "requestsFailed" as Filter,
label: t("synthetics.evidence.filterFailedReq"),
count: c.requestsFailed,
},
];
});
/** Truncate from the LEFT: the host repeats on every row, the path is what differs. */
function shortUrl(url: string | null): string {
if (!url) return "";
try {
const u = new URL(url);
return u.pathname + (u.search ? u.search : "");
} catch {
return url.length > 70 ? `${url.slice(-70)}` : url;
}
}
function statusClass(e: EvidenceEvent): string {
if (e.kind === "requestfailed" || e.kind === "crash" || e.kind === "pageerror")
return "text-status-error-text";
if (e.kind === "console")
return e.level === "error" ? "text-status-error-text" : "text-text-secondary";
const s = e.status ?? 0;
if (s >= 500) return "text-status-error-text";
if (s >= 400) return "text-status-warning-text";
if (s >= 300) return "text-text-secondary";
return "text-text-body";
}
function toggle(i: number) {
const next = new Set(expanded.value);
if (next.has(i)) next.delete(i);
else next.add(i);
expanded.value = next;
}
const downloadUrl = computed(() => (props.evidenceKey ? props.resolveUrl(props.evidenceKey) : ""));
</script>
<template>
<div class="flex flex-col gap-3 p-3" data-test="synthetics-evidence-panel">
<!-- Empty states. All four are distinct; today they all look like nothing. -->
<div
v-if="!evidenceKey"
class="text-text-secondary text-sm"
data-test="synthetics-evidence-empty"
>
<template v-if="captureOff">{{ t("synthetics.evidence.captureOff") }}</template>
<template v-else-if="runPassed">{{ t("synthetics.evidence.failuresOnly") }}</template>
<template v-else>{{ t("synthetics.evidence.none") }}</template>
</div>
<template v-else>
<!-- Header -->
<div class="flex items-center justify-between gap-2">
<span class="text-text-body text-sm">
{{ t("synthetics.evidence.title", { count: bundle.counts.all }) }}
</span>
<!-- Named for what it is: NDJSON, not JSON. A JSON pane cannot parse it. -->
<a
:href="downloadUrl"
download
class="text-text-secondary hover:text-text-body flex items-center gap-1 text-xs"
data-test="synthetics-evidence-download"
>
<OIcon name="download" size="xs" />
evidence.ndjson
</a>
</div>
<div v-if="loading" class="flex flex-col gap-2" data-test="synthetics-evidence-loading">
<OSkeleton v-for="i in 4" :key="i" type="text" class="h-4 w-full" />
</div>
<!-- A failed fetch is reported, never rendered as an empty run. -->
<div
v-else-if="loadError"
class="rounded-default border-status-error-text/30 flex items-center justify-between gap-2 border p-2 text-xs"
role="alert"
data-test="synthetics-evidence-error"
>
<span class="text-status-error-text">
{{ t("synthetics.evidence.loadFailed", { error: loadError }) }}
</span>
<button type="button" class="text-text-body underline" @click="load()">
{{ t("synthetics.evidence.retry") }}
</button>
</div>
<template v-else>
<!-- X-8.2: reduced fidelity is reported. A silently short list reads as a
quiet run. -->
<div
v-if="bundle.truncated"
class="rounded-default border-status-warning-text/30 border p-2 text-xs"
data-test="synthetics-evidence-truncated"
>
<OIcon name="warning" size="xs" class="text-status-warning-text mr-1" />
{{ t("synthetics.evidence.truncated") }}
</div>
<!-- Chips keep their counts and stay visible at zero: a hidden zero is
indistinguishable from a chip that does not exist, and "no console
errors" is information. -->
<div class="flex flex-wrap items-center gap-2">
<button
v-for="c in chips"
:key="c.key"
type="button"
class="rounded-default border-border-default border px-2 py-0.5 text-xs"
:class="[
filter === c.key ? 'bg-surface-raised text-text-body' : 'text-text-secondary',
c.count === 0 && c.key !== 'all' ? 'opacity-50' : '',
]"
:data-test="`synthetics-evidence-chip-${c.key}`"
@click="filter = c.key"
>
{{ c.label }} {{ c.count }}
</button>
<label class="text-text-secondary ml-2 flex items-center gap-1 text-xs">
<input
v-model="firstPartyOnly"
type="checkbox"
data-test="synthetics-evidence-first-party"
/>
{{ t("synthetics.evidence.firstPartyOnly") }}
</label>
</div>
<div v-if="!bundle.counts.all" class="text-text-secondary text-sm">
{{ t("synthetics.evidence.noEvents") }}
</div>
<!-- Grouped by kind. Step attribution moved onto the row: a live
158-event bundle had only two distinct step_ids, so grouping by step
produced one section of 136 and told the reader nothing. -->
<div v-for="g in visibleGroups" :key="g.kind" class="flex flex-col gap-1">
<div
class="border-border-default flex items-center gap-2 border-b pb-1 text-xs"
:class="g.hasAnomaly ? 'text-status-error-text' : 'text-text-secondary'"
:data-test="`synthetics-evidence-group-${g.kind}`"
>
<OIcon v-if="g.hasAnomaly" name="warning" size="xs" />
<span>{{ t(GROUP_LABEL[g.kind]) }}</span>
<span class="text-text-secondary">{{ g.events.length }}</span>
</div>
<div
v-for="(e, i) in g.events"
:key="`${g.kind}-${i}`"
class="hover:bg-surface-raised rounded-default flex items-start gap-2 px-1 py-0.5 font-mono text-xs"
:class="e.firstParty ? '' : 'opacity-60'"
>
<span class="w-10 shrink-0 text-right" :class="statusClass(e)">
{{
e.kind === "response" ? (e.status ?? "—") : e.kind === "requestfailed" ? "—" : ""
}}
</span>
<span class="text-text-secondary w-12 shrink-0">{{ e.method ?? e.level ?? "" }}</span>
<span class="min-w-0 flex-1 truncate" :title="e.url ?? e.text ?? e.message ?? ''">
{{ shortUrl(e.url) || e.text || e.message || e.kind }}
</span>
<!-- Which step this belongs to. Attribution kept, just not as the
grouping axis. -->
<span
class="text-text-secondary w-40 shrink-0 truncate"
:title="e.stepName ?? ''"
data-test="synthetics-evidence-row-step"
>
{{ e.stepName ?? t("synthetics.evidence.unattributed") }}
</span>
<span class="text-text-secondary w-14 shrink-0 text-right">
{{ e.durationMs != null ? `${e.durationMs}ms` : "" }}
</span>
<button
v-if="e.stack"
type="button"
class="text-text-secondary shrink-0 underline"
@click="toggle(i)"
>
{{ t("synthetics.evidence.stack") }}
</button>
</div>
</div>
</template>
</template>
</div>
</template>

View File

@ -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<string, unknown> = {}) {
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<string, unknown> = {}) =>
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");
});
});

View File

@ -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<string, unknown> = {}): Record<string, unknown> {
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<string, unknown> = {}) =>
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);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -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 () => {

View File

@ -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<Set<string>> {
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<StepStatsResult> {
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<string, unknown>[] = 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<Record<string, unknown>[]>,
executeQuery(
buildStepDefsSql(monitorId, STEP_DEFS_LIMIT),
startTime,
endTime,
"logs",
) as Promise<Record<string, unknown>[]>,
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<string, unknown>[];
}) as Promise<Record<string, unknown>[]>)
: Promise.resolve([] as Record<string, unknown>[]),
]);
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<void> {
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<void> {
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;

View File

@ -54,6 +54,9 @@ function emitStreamEvent(payload: Record<string, unknown>) {
);
}
/** 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();

View File

@ -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<T>(command: RecorderCommand): Promise<T | null> {
/**
* 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<T>(
command: RecorderCommand,
timeoutMs: number = COMMAND_TIMEOUT_MS,
): Promise<T | null> {
const nonce = nextNonce();
const timeout = new Promise<null>((resolve) =>
setTimeout(() => {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<null>((resolve) => {
timer = setTimeout(() => {
pendingCommands.delete(nonce);
resolve(null);
}, COMMAND_TIMEOUT_MS),
);
}, timeoutMs);
});
const promise = new Promise<T | null>((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<void> {
async function startRecording(targetUrl: string, testIdAttr?: string): Promise<void> {
error.value = "";
liveSteps.value = [];
currentUrl.value = targetUrl;
@ -247,7 +280,17 @@ const useSyntheticsRecorder = () => {
isRecording.value = false;
};
const res = await sendCommand<RecorderStartResponse>({ 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<RecorderStartResponse>({
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<ReplayResponse>({
action: "replay",
steps: plainSteps,
targetUrl,
});
const res = await sendCommand<ReplayResponse>(
{
action: "replay",
steps: plainSteps,
targetUrl,
},
REPLAY_TIMEOUT_MS,
);
isReplaying.value = false;
replayResult.value = res;
if (res) {

View File

@ -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();
}
});
});

View File

@ -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<StepAction, string> = {
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<StepAction, IconName> = {
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<string, string> = {
@ -74,11 +144,31 @@ export const VALUE_LABELS: Record<string, string> = {
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<string, string> = {
wait: "w-50!",
@ -131,3 +221,21 @@ export const VALUE_TOOLTIP_MAP: Record<string, string> = {
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";

View File

@ -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",

View File

@ -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 journeys 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"

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "概要",

View File

@ -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": "개요",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "Обзор",

View File

@ -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ış",

View File

@ -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",

View File

@ -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": "概览",

View File

@ -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": "總覽",

View File

@ -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 };

View File

@ -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<strin
frequency: buildFrequency(schedule),
config: {
steps: journeyToWireSteps(journey),
// `steps_version` describes the whole array, so the journey qualifies as a
// whole or not at all — see isV2Journey. A v2 payload is built field by
// field rather than spread, because the server refuses unknown fields.
...(isV2Journey(journey)
? { steps_version: 2, steps: buildV2Steps(journey) }
: { steps: journeyToWireSteps(journey) }),
browser_devices: browserDevices ?? [{ browser: "chromium", device: "desktop" }],
timeout_ms: 30000,
capture: {
@ -375,6 +381,10 @@ export function mapResponseToBrowserCheck(data: Record<string, unknown>): 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 && {

View File

@ -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> = {}): 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);
});
});

View File

@ -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<Record<StepAction, string>> = {
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<V2WireStep, "url" | "value" | "key" | "files"> {
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<V2WireStep["settle"]> = {};
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);
}

View File

@ -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);
});
});

View File

@ -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";
}

View File

@ -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> = {}): 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);
});
});

View File

@ -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<SelectorType, LocatorKind> = {
TestID: "test_attribute",
Role: "role",
Text: "text",
CSS: "css",
XPath: "xpath",
};
/** Redundant v1 aliases, collapsed onto the v2 vocabulary. */
const ACTION_ALIASES: Partial<Record<string, StepAction>> = {
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<string, string> = {
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;
}

View File

@ -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,
});
});
});

View File

@ -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<LocatorKind, number> = {
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));
}

View File

@ -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"]);
});
});

View File

@ -9,15 +9,22 @@ const ACTION_MAP: Record<string, StepAction> = {
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<string, SelectorType> = {
@ -37,7 +44,20 @@ const WIRE_SELECTOR_TYPE_MAP: Record<SelectorType, WireStep["selector_type"]> =
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":

View File

@ -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 <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import type { BrowserStep } from "@/types/synthetics";
import { stepIsMissingTarget } from "./stepTarget";
function step(partial: Partial<BrowserStep>): 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);
});
});

View File

@ -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 <http://www.gnu.org/licenses/>.
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);
}

View File

@ -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<BrowserCheck>({
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<boolean> {
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"

View File

@ -77,6 +77,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: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) {

View File

@ -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<string, any[]>();
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: '<div data-test="monitor-runs-tabs"><slot /></div>',
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();

View File

@ -811,6 +811,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- STEPS -->
<OTabPanel name="steps">
<div class="mx-auto flex flex-col gap-2">
<!--
P2a the tally takes the newest N executions, so on a busy check
it describes a window far shorter than the one the picker shows.
A 1-minute check across 2 locations x 4 browser/device combos
produces 11 520 executions a day: 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.
-->
<p
v-if="!stepsLoading && stepsCoverage.truncated"
class="text-text-secondary px-2 text-xs"
data-test="monitor-runs-steps-coverage"
>
{{
t("synthetics.runs.stepsWindowTruncated", {
count: stepsCoverage.executions,
from: fmtTimestamp(stepsCoverage.fromMs),
to: fmtTimestamp(stepsCoverage.toMs),
})
}}
</p>
<!-- Loading skeleton -->
<template v-if="stepsLoading || !stepsHasLoadedOnce">
<div class="grid grid-cols-2 gap-2">
@ -1043,7 +1064,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { useStore } from "vuex";
@ -1135,13 +1156,23 @@ interface Props {
/** Check type ("browser" | "http" | etc.) provided by the parent after it
* fetches the check. Controls the tabs grid layout and step analysis visibility. */
checkType?: string;
/** The check's configured retry count.
*
* At 0 the probe never retries, so no run can ever be observed as flaky and
* `retry_step_ids` is never written. The tiles must read "—", not "0.0%":
* a zero is a measurement, and this is the absence of one. */
retries?: number;
}
const props = withDefaults(defineProps<Props>(), {
monitorStatus: "healthy",
lastTriggeredAt: 0,
checkType: "browser",
retries: 0,
});
/** Whether flakiness is observable at all for this check. */
const retriesEnabled = computed(() => props.retries > 0);
// Synthetic results composable
const synthetics = useSyntheticResults();
const kpiLoading = computed(() => synthetics.kpiLoading.value);
@ -1482,14 +1513,29 @@ const kpiCards = computed<KpiCard[]>(() => {
{
key: "retry-rate",
label: t("synthetics.runs.retryRate"),
value: k.totalRuns > 0 ? ((k.retriedRuns / k.totalRuns) * 100).toFixed(1) + "%" : "0.0%",
value: !retriesEnabled.value
? "—"
: k.totalRuns > 0
? ((k.retriedRuns / k.totalRuns) * 100).toFixed(1) + "%"
: "0.0%",
valueClass: k.retriedRuns > 0 ? "text-text-body!" : undefined,
},
{
key: "warning-runs",
label: t("synthetics.runs.warningRuns"),
value: String(k.warningRuns),
valueClass: k.warningRuns > 0 ? "text-status-warning-text!" : undefined,
// D4 the denominator is EXECUTIONS, the grain `totalRuns` and the
// runs list both use. Dividing by scheduled runs instead would be
// smaller by the location × browser × device fan-out and inflate this
// several-fold.
key: "flaky-rate",
label: t("synthetics.runs.flakyRate"),
// "" at retries = 0, never "0.0%". A check that cannot retry cannot be
// seen to recover, so a zero here would read as "nothing is flaky" when
// the truth is "flakiness was never measurable".
value: !retriesEnabled.value
? "—"
: k.totalRuns > 0
? ((k.flakyExecutions / k.totalRuns) * 100).toFixed(1) + "%"
: "0.0%",
valueClass: k.flakyExecutions > 0 ? "text-status-warning-text!" : undefined,
},
{
key: "failed-runs",
@ -2133,6 +2179,24 @@ const runColumns = computed<OTableColumnDef[]>(() => {
// Steps: real data from composable
const stepGroupsData = computed(() => synthetics.stepStats.value.stepGroups);
/** What the tally actually covered, so the panel can say so when the row cap
* bound rather than the selected time range (P2a). */
const stepsCoverage = computed(
() =>
synthetics.stepStats.value.coverage ?? {
executions: 0,
fromMs: 0,
toMs: 0,
// Absent coverage means "this result predates the field", which is not
// evidence the window was truncated. The banner stays hidden.
truncated: false,
},
);
/** Epoch ms as a local timestamp, matching the other stamps on this page. */
function fmtTimestamp(ms: number): string {
return ms > 0 ? new Date(ms).toLocaleString() : "—";
}
// Display helpers for step analysis
@ -2436,11 +2500,40 @@ function openRun(row: { id: number }) {
emit("open-run", String(row.id), "");
}
// Steps tab loaded lazily
//
// The step aggregation walks the REST /runs payload and is the heaviest query
// the page issues, so it runs only when the Steps tab is actually in view. Two
// triggers, and nothing else: opening the tab, and a new time window while the
// tab is open. A window change with the tab closed just marks the aggregation
// stale so the next visit refetches.
/** Whether `stepStats` was computed over the window in `timeRangeMicros`. */
const stepsMatchWindow = ref(false);
async function loadSteps() {
const tr = timeRangeMicros.value;
if (!tr) return;
stepsMatchWindow.value = true;
await synthetics.fetchSteps(props.monitorId, tr.startTime, tr.endTime);
}
watch(activeTab, (tab) => {
if (tab === "steps" && !stepsMatchWindow.value) void loadSteps();
});
// Public API parent drives all (re)loads
async function refresh(startTime?: number, endTime?: number) {
if (!startTime || !endTime) return;
timeRangeMicros.value = { startTime, endTime };
await synthetics.fetchAll(props.monitorId, startTime, endTime);
// The new window invalidates whatever the Steps tab is holding. Refetch right
// away if it is the open tab this is also the path the steps error-state
// retry button takes otherwise defer to the next time it is opened.
stepsMatchWindow.value = false;
await Promise.all([
synthetics.fetchAll(props.monitorId, startTime, endTime),
activeTab.value === "steps" ? loadSteps() : Promise.resolve(),
]);
}
defineExpose({ refresh });

View File

@ -48,6 +48,8 @@ vi.mock("vuex", () => ({
}),
}));
const mockRunDetailRef: { value: any } = { value: null };
const mockRunDetail = {
timestamp: Date.now() / 1000,
scheduledTs: Date.now() / 1000,
@ -70,6 +72,51 @@ const mockRunDetail = {
network: null,
webVitals: null,
traceKey: null,
// Fields the tab bar, attempt selector and evidence panel read. The fixture
// predated all of them, so it could not have caught a panel that never
// rendered.
initMs: 0,
startedTs: 0,
queueDelayMs: null,
statusReason: "",
errorSource: "",
failureDetail: null,
evidenceByStep: [],
evidenceKey: null,
evidenceTruncated: false,
};
/** A retried execution: two attempts, the second deciding. */
const mockRetriedDetail = {
...mockRunDetail,
status: "failed",
attempts: 2,
failedStep: "fa1",
evidenceKey: "synthetics/org/mon/2026/07/29/RUN/EXEC/attempt-1-evidence.ndjson",
retryHistory: [
{
attempt: 0,
status: "failed",
durationMs: 57795,
failedStep: "fa1",
steps: [],
failureDetail: null,
screenshotKeys: new Map(),
traceKey: null,
evidenceKey: "…/evidence.ndjson",
},
{
attempt: 1,
status: "failed",
durationMs: 58341,
failedStep: "fa1",
steps: [],
failureDetail: null,
screenshotKeys: new Map(),
traceKey: null,
evidenceKey: "…/attempt-1-evidence.ndjson",
},
],
};
// ── Controllable `loading` ref shared with the mocked composable, so
@ -96,7 +143,7 @@ vi.mock("@/composables/useSyntheticResults", () => ({
},
buckets: { value: [] },
runs: { value: [] },
runDetail: { value: { ...mockRunDetail } },
runDetail: mockRunDetailRef,
loading: mockLoading,
error: { value: null },
hasLoadedOnce: { value: true },
@ -169,6 +216,7 @@ describe("RunDetail", () => {
let wrapper: VueWrapper;
beforeEach(async () => {
mockRunDetailRef.value = { ...mockRunDetail };
wrapper = mountComponent();
await flushPromises();
});
@ -286,3 +334,44 @@ describe("RunDetail", () => {
});
});
});
// ── Tabs and the attempt selector ───────────────────────────────────────────
//
// Both were previously "verified" from a screenshot, and both were wrong twice.
// These assert the DOM.
describe("RunDetail — steps / evidence tabs", () => {
beforeEach(() => {
mockRunDetailRef.value = { ...mockRunDetail };
});
afterEach(() => {
mockRunDetailRef.value = { ...mockRunDetail };
});
it("renders both tabs, with Steps selected first", async () => {
const w = mountComponent();
await flushPromises();
expect(w.find('[data-test="synthetics-run-detail-tab-steps"]').exists()).toBe(true);
expect(w.find('[data-test="synthetics-run-detail-tab-evidence"]').exists()).toBe(true);
w.unmount();
});
it("hides the attempt selector on a run that never retried", async () => {
const w = mountComponent();
await flushPromises();
// One attempt means nothing to select; a control with a single option is
// noise in an already-dense drawer.
expect(w.find('[data-test="synthetics-run-detail-attempt-select"]').exists()).toBe(false);
w.unmount();
});
it("shows the attempt selector on a retried run", async () => {
mockRunDetailRef.value = { ...mockRetriedDetail } as any;
const w = mountComponent();
await flushPromises();
expect(w.find('[data-test="synthetics-run-detail-attempt-select"]').exists()).toBe(true);
expect(w.find('[data-test="synthetics-run-detail-attempt-dropdown"]').exists()).toBe(true);
w.unmount();
});
});

View File

@ -157,284 +157,360 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</template>
<!-- Steps skeleton -->
<template v-if="loading">
<OCard class="gap-0 p-0">
<OCardSection role="header" class="gap-2">
<OSkeleton type="text" class="h-4 w-14" />
</OCardSection>
<OSeparator />
<OCardSection role="body" class="flex flex-col gap-2 p-3">
<div
v-for="i in 4"
:key="i"
class="rounded-default border-border-default flex items-center gap-2 border p-2"
>
<OSkeleton type="rect" class="rounded-default h-12 w-18 shrink-0" />
<OSkeleton type="circle" class="h-6 w-6 shrink-0" />
<OSkeleton type="text" class="h-4 flex-1" />
<OSkeleton type="text" class="h-4 w-16 shrink-0" />
</div>
</OCardSection>
</OCard>
</template>
<!-- Lambda execution error (no steps) -->
<!-- Attempts: a compact selector, because the info bar is already six
chips wide and a retried run adds nothing the chip does not say. -->
<div
v-else-if="isErrorRun"
class="border-badge-error-ol-border/30 rounded-default m-2 overflow-hidden border bg-[var(--color-badge-error-soft-bg)]"
role="alert"
data-test="synthetics-run-detail-steps-error-banner"
v-if="!loading && attemptViews.length > 1"
class="flex items-center gap-2 px-2 pt-3"
data-test="synthetics-run-detail-attempt-select"
>
<div class="flex items-start gap-2 p-3">
<OIcon name="error" class="text-status-error-text shrink-0" size="md" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-status-error-text text-sm font-bold">
{{ currentRun.errorType }}
</span>
</div>
<OButton
v-if="currentRun.errorStack"
variant="ghost-destructive"
size="xs"
class="mt-1"
data-test="synthetics-run-detail-error-expand-btn"
@click="stackOpen = !stackOpen"
>
<template #icon-left>
<OIcon
name="expand-more"
size="xs"
class="transition-transform duration-150"
:class="{ 'rotate-180': stackOpen }"
/>
</template>
<span class="text-2xs text-status-error-text font-semibold">
{{ t("synthetics.runDetail.viewFullError") }}
</span>
</OButton>
<pre
v-if="stackOpen && currentRun.errorStack"
class="text-2xs text-text-body bg-code-bg rounded-default mt-2 overflow-auto p-[10px_12px] font-mono leading-[1.6] whitespace-pre-wrap"
data-test="synthetics-run-detail-error-stack"
>{{ currentRun.errorStack }}</pre
>
</div>
</div>
<span class="text-text-secondary text-xs">
{{ t("synthetics.runDetail.attemptsLabel", { count: attemptViews.length }) }}
</span>
<OSelect
v-model="selectedAttemptValue"
:options="attemptOptions"
size="sm"
class="w-56"
data-test="synthetics-run-detail-attempt-dropdown"
/>
<!-- Superseded attempts keep only a compact timeline; the full
forensics are retained for the attempt that decided the run. -->
<span
v-if="currentAttempt?.compact"
class="text-text-secondary text-xs"
data-test="synthetics-run-detail-attempt-reduced"
>
{{ t("synthetics.runDetail.attemptReducedDetail") }}
</span>
</div>
<!-- Split: Replay Player (left) + Steps Timeline (right) -->
<div v-else-if="steps.length > 0" class="flex min-h-0 flex-1 items-start">
<!-- Left: Session Replay Player -->
<OCard v-if="currentRun.hasReplay" class="w-[30%] min-w-[30rem] gap-0 p-0">
<OCardSection role="header" class="gap-2">
<OIcon name="smart_display" size="sm" class="text-accent" />
<span class="text-text-heading text-sm font-bold">{{
t("synthetics.runDetail.sessionReplay")
}}</span>
<span class="flex-1" />
<span class="text-2xs text-text-secondary font-mono">
{{
t("synthetics.runDetail.stepOf", {
selected: selectedStep?.id,
total: steps.length,
})
}}
</span>
</OCardSection>
<OSeparator />
<!-- Steps and Evidence are siblings, not stacked. Stacking them pushed
a 158-row event list above the step table and broke the drawer's
scroll: OTabPanels owns the scroll container (`grow scroll="y"`). -->
<OTabs
v-if="!loading"
v-model="detailTab"
class="border-border-default mt-2 shrink-0 border-b px-2"
>
<OTab name="steps" data-test="synthetics-run-detail-tab-steps">
{{ t("synthetics.runs.tabSteps") }}
</OTab>
<OTab name="evidence" data-test="synthetics-run-detail-tab-evidence">
{{ t("synthetics.runDetail.evidenceSection") }}
</OTab>
</OTabs>
<div class="flex h-95 flex-col">
<div class="min-h-0 flex-1">
<VideoPlayer :events="[]" :segments="[]" :is-loading="false" />
</div>
</div>
</OCard>
<div class="min-h-0 flex-1">
<OTabPanels v-model="detailTab" grow scroll="y" class="h-full min-h-0">
<OTabPanel name="evidence">
<!-- v-if, not v-show: this is what makes the fetch happen on open
rather than with the record. -->
<EvidencePanel
v-if="detailTab === 'evidence'"
:evidence-key="evidenceKey"
:resolve-url="screenshotUrl"
:step-defs="evidenceStepDefs"
:record-truncated="evidenceTruncated"
:run-passed="currentRun.status === 'pass'"
/>
</OTabPanel>
<!-- Right: Execution Timeline -->
<div class="flex h-full min-h-0 min-w-0 flex-1 flex-col">
<div class="flex items-center gap-2 px-3 py-4">
<h4 class="text-text-heading m-0 text-sm font-bold">
{{ t("synthetics.journey.steps") }}
</h4>
<OBadge variant="default" size="sm">{{ steps.length }}</OBadge>
<span class="flex-1" />
</div>
<div class="min-h-0 flex-1 overflow-auto pb-2">
<!-- JourneySteps in results mode -->
<JourneySteps
:data="stepsWithTotal"
mode="results"
action-key="action"
name-key="name"
detail-key="detail"
icon-key="icon"
:dot-state-fn="stepDotState"
:expanded-ids="expandedStepIdsArr"
@update:expanded-ids="handleUpdateExpanded"
>
<!-- Screenshot thumbnail -->
<template #screenshot-thumb="{ row }">
<img
v-if="row.screenshotKey"
:src="screenshotUrl(row.screenshotKey)"
:alt="t('synthetics.runDetail.screenshotAlt')"
class="h-full w-full object-cover"
/>
<OIcon v-else name="image" size="xs" class="text-text-secondary" />
</template>
<!-- Expanded content: screenshot + metadata + error -->
<template #expansion="{ row }">
<div class="flex gap-4 p-3">
<div class="w-[40%] shrink-0">
<div class="rounded-default border-border-default overflow-hidden border">
<div
class="flex aspect-[16/10] items-center justify-center overflow-hidden"
:class="
row.status === 'fail' ? 'bg-status-error-bg' : 'bg-surface-subtle'
"
>
<div v-if="row.screenshotKey" class="group relative h-full w-full">
<OButton
variant="ghost"
size="sm"
class="h-full! w-full rounded-none! border-0! p-0!"
data-test="synthetics-run-detail-step-screenshot-thumb"
@click="openLightbox(row.id)"
>
<img
:src="screenshotUrl(row.screenshotKey)"
:alt="t('synthetics.runDetail.screenshotAlt')"
class="h-full w-full object-contain transition-opacity group-hover:opacity-90"
/>
</OButton>
<div
class="rounded-default bg-surface-base/80 pointer-events-none absolute top-2 right-2 flex h-7 w-7 items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
>
<OIcon name="fullscreen" size="sm" class="text-text-body" />
</div>
</div>
<template v-else>
<OIcon
name="image"
:class="
row.status === 'fail'
? 'text-status-error-text'
: 'text-text-secondary'
"
size="lg"
/>
<span
class="text-xs font-semibold"
:class="
row.status === 'fail'
? 'text-status-error-text'
: 'text-text-secondary'
"
>
{{
row.status === "fail"
? t("synthetics.runDetail.failureScreenshot")
: t("synthetics.runDetail.screenshotPlaceholder")
}}
</span>
</template>
</div>
</div>
<OTabPanel name="steps">
<!-- Steps skeleton -->
<template v-if="loading">
<OCard class="gap-0 p-0">
<OCardSection role="header" class="gap-2">
<OSkeleton type="text" class="h-4 w-14" />
</OCardSection>
<OSeparator />
<OCardSection role="body" class="flex flex-col gap-2 p-3">
<div
v-for="i in 4"
:key="i"
class="rounded-default border-border-default flex items-center gap-2 border p-2"
>
<OSkeleton type="rect" class="rounded-default h-12 w-18 shrink-0" />
<OSkeleton type="circle" class="h-6 w-6 shrink-0" />
<OSkeleton type="text" class="h-4 flex-1" />
<OSkeleton type="text" class="h-4 w-16 shrink-0" />
</div>
</OCardSection>
</OCard>
</template>
<!-- Lambda execution error (no steps) -->
<div
v-else-if="isErrorRun"
class="border-badge-error-ol-border/30 rounded-default m-2 overflow-hidden border bg-[var(--color-badge-error-soft-bg)]"
role="alert"
data-test="synthetics-run-detail-steps-error-banner"
>
<div class="flex items-start gap-2 p-3">
<OIcon name="error" class="text-status-error-text shrink-0" size="md" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-status-error-text text-sm font-bold">
{{ currentRun.errorType }}
</span>
</div>
<OButton
v-if="currentRun.errorStack"
variant="ghost-destructive"
size="xs"
class="mt-1"
data-test="synthetics-run-detail-error-expand-btn"
@click="stackOpen = !stackOpen"
>
<template #icon-left>
<OIcon
name="expand-more"
size="xs"
class="transition-transform duration-150"
:class="{ 'rotate-180': stackOpen }"
/>
</template>
<span class="text-2xs text-status-error-text font-semibold">
{{ t("synthetics.runDetail.viewFullError") }}
</span>
</OButton>
<pre
v-if="stackOpen && currentRun.errorStack"
class="text-2xs text-text-body bg-code-bg rounded-default mt-2 overflow-auto p-[10px_12px] font-mono leading-[1.6] whitespace-pre-wrap"
data-test="synthetics-run-detail-error-stack"
>{{ currentRun.errorStack }}</pre
>
</div>
</div>
</div>
<div class="flex flex-1 flex-col gap-4">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
<dt
class="text-text-secondary text-sm font-semibold tracking-wide capitalize"
>
{{ t("synthetics.runDetail.detailAction") }}
</dt>
<dd class="text-text-secondary">{{ row.action }}</dd>
<dt
class="text-text-secondary text-sm font-semibold tracking-wide capitalize"
>
{{ t("synthetics.runDetail.detailSelector") }}
</dt>
<dd class="text-text-secondary">{{ row.detail }}</dd>
<dt
class="text-text-secondary text-sm font-semibold tracking-wide capitalize"
>
{{ t("synthetics.runDetail.detailUrl") }}
</dt>
<dd class="text-text-secondary truncate">
{{ row.url || currentRun.url }}
</dd>
<dt
class="text-text-secondary text-sm font-semibold tracking-wide capitalize"
>
{{ t("synthetics.results.duration") }}
</dt>
<dd class="text-text-secondary">{{ row.durStr }}</dd>
</dl>
<!-- Split: Replay Player (left) + Steps Timeline (right) -->
<div v-else-if="steps.length > 0" class="flex min-h-0 flex-1 items-start">
<!-- Left: Session Replay Player -->
<OCard v-if="currentRun.hasReplay" class="w-[30%] min-w-[30rem] gap-0 p-0">
<OCardSection role="header" class="gap-2">
<OIcon name="smart_display" size="sm" class="text-accent" />
<span class="text-text-heading text-sm font-bold">{{
t("synthetics.runDetail.sessionReplay")
}}</span>
<span class="flex-1" />
<span class="text-2xs text-text-secondary font-mono">
{{
t("synthetics.runDetail.stepOf", {
selected: selectedStep?.id,
total: steps.length,
})
}}
</span>
</OCardSection>
<OSeparator />
<div
v-if="row.status === 'fail' && row.error"
class="rounded-default border-badge-error-ol-border/30 overflow-hidden border"
:data-test="`synthetics-run-detail-step-error-card-${row.id}`"
>
<div
class="flex items-center gap-2 bg-[var(--color-badge-error-soft-bg)] px-3 py-2"
>
<OIcon
name="error"
size="sm"
class="text-status-error-text"
aria-hidden="true"
/>
<span class="text-text-heading flex-1 text-xs font-semibold">{{
t("synthetics.results.error")
}}</span>
</div>
<div class="px-3 py-3">
<pre
class="text-text-body m-0 font-mono text-xs leading-relaxed whitespace-pre-wrap"
:class="{
'max-h-24 overflow-hidden':
!expandedStepErrors.has(row.id) && (row.error?.length ?? 0) > 200,
}"
>{{ row.error }}</pre
>
<div class="mt-1.5 flex items-center gap-2">
<OButton
v-if="(row.error?.length ?? 0) > 200"
variant="ghost"
size="xs"
class="text-text-link text-xs font-semibold"
data-test="synthetics-run-detail-toggle-step-error-btn"
@click="toggleStepError(row.id)"
>
{{
expandedStepErrors.has(row.id)
? t("synthetics.runDetail.showLess")
: t("synthetics.runDetail.showFullError")
}}
</OButton>
<OButton
variant="ghost"
size="xs"
data-test="synthetics-run-detail-step-view-error-btn"
@click="openErrorFullscreen(row.id)"
>
{{ t("synthetics.runDetail.viewFullErrorBtn") }}
</OButton>
</div>
</div>
</div>
<div class="flex h-95 flex-col">
<div class="min-h-0 flex-1">
<VideoPlayer :events="[]" :segments="[]" :is-loading="false" />
</div>
</div>
</template>
</JourneySteps>
</div>
</div>
</OCard>
<!-- Right: Execution Timeline -->
<div class="flex h-full min-h-0 min-w-0 flex-1 flex-col">
<div class="flex items-center gap-2 px-3 py-4">
<h4 class="text-text-heading m-0 text-sm font-bold">
{{ t("synthetics.journey.steps") }}
</h4>
<OBadge variant="default" size="sm">{{ steps.length }}</OBadge>
<span class="flex-1" />
</div>
<div class="min-h-0 flex-1 overflow-auto pb-2">
<!-- JourneySteps in results mode -->
<JourneySteps
:data="stepsWithTotal"
mode="results"
action-key="action"
name-key="name"
detail-key="detail"
icon-key="icon"
:dot-state-fn="stepDotState"
:expanded-ids="expandedStepIdsArr"
@update:expanded-ids="handleUpdateExpanded"
>
<!-- Screenshot thumbnail -->
<template #screenshot-thumb="{ row }">
<img
v-if="row.screenshotKey"
:src="screenshotUrl(row.screenshotKey)"
:alt="t('synthetics.runDetail.screenshotAlt')"
class="h-full w-full object-cover"
/>
<OIcon v-else name="image" size="xs" class="text-text-secondary" />
</template>
<!-- Expanded content: screenshot + metadata + error -->
<template #expansion="{ row }">
<div class="flex gap-4 p-3">
<div class="w-[40%] shrink-0">
<div
class="rounded-default border-border-default overflow-hidden border"
>
<div
class="flex aspect-[16/10] items-center justify-center overflow-hidden"
:class="
row.status === 'fail' ? 'bg-status-error-bg' : 'bg-surface-subtle'
"
>
<div v-if="row.screenshotKey" class="group relative h-full w-full">
<OButton
variant="ghost"
size="sm"
class="h-full! w-full rounded-none! border-0! p-0!"
data-test="synthetics-run-detail-step-screenshot-thumb"
@click="openLightbox(row.id)"
>
<img
:src="screenshotUrl(row.screenshotKey)"
:alt="t('synthetics.runDetail.screenshotAlt')"
class="h-full w-full object-contain transition-opacity group-hover:opacity-90"
/>
</OButton>
<div
class="rounded-default bg-surface-base/80 pointer-events-none absolute top-2 right-2 flex h-7 w-7 items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
>
<OIcon name="fullscreen" size="sm" class="text-text-body" />
</div>
</div>
<template v-else>
<OIcon
name="image"
:class="
row.status === 'fail'
? 'text-status-error-text'
: 'text-text-secondary'
"
size="lg"
/>
<span
class="text-xs font-semibold"
:class="
row.status === 'fail'
? 'text-status-error-text'
: 'text-text-secondary'
"
>
{{
row.status === "fail"
? t("synthetics.runDetail.failureScreenshot")
: t("synthetics.runDetail.screenshotPlaceholder")
}}
</span>
</template>
</div>
</div>
</div>
<div class="flex flex-1 flex-col gap-4">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
<dt
class="text-text-secondary text-sm font-semibold tracking-wide capitalize"
>
{{ t("synthetics.runDetail.detailAction") }}
</dt>
<dd class="text-text-secondary">{{ row.action }}</dd>
<dt
class="text-text-secondary text-sm font-semibold tracking-wide capitalize"
>
{{ t("synthetics.runDetail.detailSelector") }}
</dt>
<dd class="text-text-secondary">{{ row.detail }}</dd>
<dt
class="text-text-secondary text-sm font-semibold tracking-wide capitalize"
>
{{ t("synthetics.runDetail.detailUrl") }}
</dt>
<dd class="text-text-secondary truncate">
{{ row.url || currentRun.url }}
</dd>
<dt
class="text-text-secondary text-sm font-semibold tracking-wide capitalize"
>
{{ t("synthetics.results.duration") }}
</dt>
<dd class="text-text-secondary">{{ row.durStr }}</dd>
</dl>
<div
v-if="row.status === 'fail' && row.error"
class="rounded-default border-badge-error-ol-border/30 overflow-hidden border"
:data-test="`synthetics-run-detail-step-error-card-${row.id}`"
>
<div
class="flex items-center gap-2 bg-[var(--color-badge-error-soft-bg)] px-3 py-2"
>
<OIcon
name="error"
size="sm"
class="text-status-error-text"
aria-hidden="true"
/>
<span class="text-text-heading flex-1 text-xs font-semibold">{{
t("synthetics.results.error")
}}</span>
</div>
<div class="px-3 py-3">
<pre
class="text-text-body m-0 font-mono text-xs leading-relaxed whitespace-pre-wrap"
:class="{
'max-h-24 overflow-hidden':
!expandedStepErrors.has(row.id) &&
(row.error?.length ?? 0) > 200,
}"
>{{ row.error }}</pre
>
<div class="mt-1.5 flex items-center gap-2">
<OButton
v-if="(row.error?.length ?? 0) > 200"
variant="ghost"
size="xs"
class="text-text-link text-xs font-semibold"
data-test="synthetics-run-detail-toggle-step-error-btn"
@click="toggleStepError(row.id)"
>
{{
expandedStepErrors.has(row.id)
? t("synthetics.runDetail.showLess")
: t("synthetics.runDetail.showFullError")
}}
</OButton>
<OButton
variant="ghost"
size="xs"
data-test="synthetics-run-detail-step-view-error-btn"
@click="openErrorFullscreen(row.id)"
>
{{ t("synthetics.runDetail.viewFullErrorBtn") }}
</OButton>
</div>
</div>
</div>
<!-- P5.4 items 3-5: what the runner saw. Written by the
probe on every failed run and rendered by nothing
until now, which is why every failure looked alike. -->
<StepEvidence
v-if="row.evidence"
:detail="row.evidence"
:evidence="row.appEvidence"
:truncated="evidenceTruncated"
class="mt-3"
/>
</div>
</div>
</template>
</JourneySteps>
</div>
</div>
</div>
</OTabPanel>
</OTabPanels>
</div>
</div>
</div>
@ -500,6 +576,7 @@ import OCardSection from "@/lib/core/Card/OCardSection.vue";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import StepEvidence from "@/components/synthetics/StepEvidence.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import BetaBadge from "@/components/common/BetaBadge.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
@ -511,7 +588,17 @@ import JourneySteps from "@/components/synthetics/journey/JourneySteps.vue";
import type { StepDotState } from "@/components/synthetics/journey/JourneySteps.vue";
import useSyntheticResults from "@/composables/useSyntheticResults";
import ProtocolRunSummary from "@/components/synthetics/results/ProtocolRunSummary.vue";
import EvidencePanel from "@/components/synthetics/results/EvidencePanel.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import OTabPanels from "@/lib/navigation/Tabs/OTabPanels.vue";
import OTabPanel from "@/lib/navigation/Tabs/OTabPanel.vue";
import { buildAttemptViews } from "@/composables/synthetics/syntheticResultsSchema";
import type {
AttemptView,
FailureDetail,
StepEvidence as StepEvidenceSummary,
SyntheticRunDetail,
RecordedStep,
} from "@/composables/synthetics/syntheticResultsSchema";
@ -645,6 +732,10 @@ interface StepRow {
durColor: string;
error: string | null;
screenshotKey: string | null;
/** P5.4 items 3-5, present only on the step that actually failed. */
evidence: FailureDetail | null;
/** Browser-side evidence for this step, when the probe captured any. */
appEvidence: StepEvidenceSummary | null;
}
function fmtDur(ms: number): string {
@ -652,13 +743,24 @@ function fmtDur(ms: number): string {
}
/** Merge recorded_step definitions with last_attempt_step execution results. */
function buildSteps(detail: SyntheticRunDetail | null): StepRow[] {
if (!detail || !detail.lastAttemptSteps.length) return [];
/**
* @param attempt the attempt being viewed. Its steps and failure detail are
* used in place of the record's top-level ones, so switching attempts
* re-renders the table instead of showing the deciding attempt's steps under
* another attempt's label.
*/
function buildSteps(
detail: SyntheticRunDetail | null,
attempt: AttemptView | null = null,
): StepRow[] {
const steps = attempt?.steps ?? detail?.lastAttemptSteps ?? [];
const failureDetail = attempt ? attempt.failureDetail : (detail?.failureDetail ?? null);
if (!detail || !steps.length) return [];
const recordedMap = new Map<string, RecordedStep>();
for (const rs of detail.recordedSteps) {
recordedMap.set(rs.id, rs);
}
return detail.lastAttemptSteps.map((ex, idx) => {
return steps.map((ex, idx) => {
const recorded = recordedMap.get(ex.step_id);
const isFail = ex.status === "fail";
return {
@ -675,7 +777,17 @@ function buildSteps(detail: SyntheticRunDetail | null): StepRow[] {
durStr: fmtDur(ex.duration_ms),
durColor: isFail ? "var(--color-status-error-text)" : "var(--color-text-secondary)",
error: ex.error,
screenshotKey: ex.screenshot_key,
// A superseded attempt's screenshots are uploaded under an attempt-scoped
// key, so they are resolved from THAT attempt's refs. Falling back to the
// record's key would show the surviving attempt's pixels under the
// failing attempt's label.
screenshotKey: attempt?.screenshotKeys.get(ex.step_id) ?? ex.screenshot_key,
// Scoped to the failing step: the report describes one failure, and
// hanging it off every row would imply each step had its own.
evidence: failureDetail && failureDetail.stepId === ex.step_id ? failureDetail : null,
// Per step, not per failure: the step that CAUSED the problem is often
// not the one that failed, so evidence hangs off whichever step owns it.
appEvidence: detail.evidenceByStep.find((e) => e.stepId === ex.step_id) ?? null,
};
});
}
@ -731,9 +843,21 @@ const artifactUrls = ref<Record<string, string>>({});
async function presignRunArtifacts() {
const detail = synthetics.runDetail.value;
if (!detail) return;
const keys = [...detail.lastAttemptSteps.map((s) => s.screenshot_key), detail.traceKey].filter(
(k): k is string => !!k,
);
// Every attempt's artifacts, not only the deciding one's: switching attempts
// in the strip must not trigger a second presign round-trip, and a superseded
// attempt's screenshots live under their own keys.
const keys = [
...detail.lastAttemptSteps.map((s) => s.screenshot_key),
detail.traceKey,
detail.evidenceKey,
// Evidence bundles as well as screenshots and traces, so opening the
// Evidence tab and switching attempts inside it cost no further round-trip.
...detail.retryHistory.flatMap((a) => [
...a.screenshotKeys.values(),
a.traceKey,
a.evidenceKey,
]),
].filter((k): k is string => !!k);
if (!keys.length) return;
const orgId = store.state.selectedOrganization.identifier;
try {
@ -771,6 +895,32 @@ function screenshotUrl(key: string | null): string {
return syntheticsService.artifactUrl(orgId, key, folderName.value);
}
// Evidence tab
//
// The panel reads the BUNDLE, not `evidence_by_step`: that field is an anomaly
// index and is empty whenever the network behaved, which is the common shape of
// a browser failure (a locator that never matched).
/** Which panel the drawer is showing. Steps first — it is what the run is. */
const detailTab = ref<"steps" | "evidence">("steps");
/** The SELECTED attempt's own bundle — attempt 0 bare, retries `attempt-N-`. */
/** `evidence_truncated` from the record, for both the step rows and the panel. */
const evidenceTruncated = computed(() => synthetics.runDetail.value?.evidenceTruncated ?? false);
const evidenceKey = computed(
() => currentAttempt.value?.evidenceKey ?? synthetics.runDetail.value?.evidenceKey ?? null,
);
/** step_id -> definition, for naming groups. Reuses the run's own snapshot so a
* later edit to the check cannot relabel this run's history. */
const evidenceStepDefs = computed(() => {
const m = new Map<string, { name: string; selector: string | null }>();
for (const rs of synthetics.runDetail.value?.recordedSteps ?? []) {
m.set(rs.id, { name: rs.name || rs.id, selector: rs.selector });
}
return m;
});
// Display model for the current run (mapped from SyntheticRunDetail)
interface DisplayRun {
id: string;
@ -834,6 +984,51 @@ function toDisplayRun(detail: SyntheticRunDetail | null): DisplayRun {
};
}
// Attempts (C2)
//
// One record carries every attempt the execution made. Switching between them
// is local state `retry_history` is already on the row, so the strip costs no
// request.
const attemptViews = computed<AttemptView[]>(() =>
synthetics.runDetail.value ? buildAttemptViews(synthetics.runDetail.value) : [],
);
const selectedAttempt = ref(0);
// The deciding attempt is the default: it is the one the run's verdict and the
// record's top-level fields describe. Reset on every new run, or the index
// would point into the previous run's (possibly shorter) list.
watch(attemptViews, (views) => {
selectedAttempt.value = Math.max(0, views.length - 1);
});
const currentAttempt = computed<AttemptView | null>(
() => attemptViews.value[selectedAttempt.value] ?? null,
);
/**
* Options for the attempt selector.
*
* Labelled 1-based ("Attempt 2 of 3") while `attempt` on the record is 0-based;
* displaying the raw index invites off-by-one bug reports. The deciding attempt
* is marked, because that is the one the record's top-level fields describe
* and on a flaky run it is the attempt that PASSED while the run reads warning.
*/
const attemptOptions = computed(() =>
attemptViews.value.map((a, i) => ({
label:
`${t("synthetics.runDetail.attemptN", { n: a.attempt + 1 })} · ${fmtDur(a.durationMs)}` +
` · ${a.status === "passed" ? t("synthetics.results.passed") : t("synthetics.results.failed")}` +
(a.decided ? ` · ${t("synthetics.runDetail.attemptDecided")}` : ""),
value: String(i),
})),
);
/** OSelect works in strings; the index is the identity. */
const selectedAttemptValue = computed({
get: () => String(selectedAttempt.value),
set: (v: string) => {
selectedAttempt.value = Number(v);
},
});
// State
const stackOpen = ref(true);
@ -925,7 +1120,7 @@ const displayMonitorName = computed(
const steps = computed<StepRow[]>(() => {
if (synthetics.runDetail.value) {
return buildSteps(synthetics.runDetail.value);
return buildSteps(synthetics.runDetail.value, currentAttempt.value);
}
return [];
});
@ -1050,8 +1245,45 @@ const infoChips = computed<InfoChip[]>(() => [
value: locationLabel(currentRun.value.location),
icon: locationIcon(currentRun.value.location),
},
// C4 probe start-up is INSIDE the duration above. Shown separately rather
// than subtracted, because a cold Lambda's 113s init is itself the finding:
// unlabelled it made every Lambda location look permanently slower than a
// private agent at every percentile.
...(initMs.value > 0
? [
{
label: t("synthetics.runDetail.initTime"),
value: fmtDur(initMs.value),
icon: "bolt",
},
]
: []),
// C5 scheduled started. Null (not 0) when the record predates the field,
// so an unknown delay is never rendered as a perfect one.
...(queueDelayMs.value !== null
? [
{
label: t("synthetics.runDetail.queueDelay"),
value: fmtDur(queueDelayMs.value),
icon: "schedule",
},
]
: []),
...(attemptViews.value.length > 1
? [
{
label: t("synthetics.runDetail.attempts"),
value: `${attemptViews.value.length}`,
icon: "replay",
colorClass: "text-status-warning-text",
},
]
: []),
]);
const initMs = computed(() => synthetics.runDetail.value?.initMs ?? 0);
const queueDelayMs = computed(() => synthetics.runDetail.value?.queueDelayMs ?? null);
// Emit status to parent (for drawer header-right badge)
watch(
() => synthetics.runDetail.value?.status ?? null,