feat(synthetics): author-owned locators, and step-scoped run evidence (#13563)
## Summary
Three synthetics workstreams on one branch: author-owned locators (Phase
2b), retirement of version-1 monitors (Phase 2c), and making a failed
browser step diagnosable without leaving the Steps tab.
Everything else in the synthetics suite passes (422 tests across the 21
suites the evidence work touches).
## Author-owned locators (Phase 2b)
The recorder proposes a locator bundle; the author owns its order.
Schema, combined-locator build/read, provenance pinned across the
recorder bridge, and the payload contract tested from both sides.
`337911158d` `e63f8f429f` `50b811a865` `2a978b9408` `c8c081c3c5`
`35ef1c36cb`
## Version-1 retirement (Phase 2c)
Stop creating v1 monitors, delete the v1 migration path and its step
fields, drop `steps_version` from the schema.
`b58b03077d` `75984ad222` `68a33e885d` `a2bffae7b7`
## Run detail — evidence, scoped to the step
Implements §5.3 of `docs/synthetics/step-failure-evidence-design.md`
("render the bundle, scoped to the step"), which was specified and never
built: only the kind-grouped run-level Evidence tab shipped.
**Per-step page activity.** The expanded step now shows the
`evidence.ndjson` events attributed to it — severity-ranked, capped at
5, with "View all N →" deep-linking into the Evidence tab pre-filtered
to that step. One shared `OTable` wrapper renders rows for both surfaces
so they cannot drift, and one composable fetches the bundle once for
both.
`a483796d57` `a7cbe0a3e0` `f63973285e` `eb7ec4f3e6` `1ed226febe`
`e3d969bcb2`
**Settle signals were attributed to the wrong step.**
`failure_detail.settle_signals` accumulates across the whole journey. On
live run `3HFCZ3fm` it held 13 signals — one from step 2, six from step
8, two from step 14 — while the failed step 15 owned none of them, and
the panel rendered all 13 under step 15. The signal that mattered
(`stale POST **/_search_stream, 30274ms`) belongs to step 14, which the
run's own error string says, and step 14 displayed nothing.
`last_attempt_steps[i].settle_signals` had carried correct per-step
attribution all along and was read by nothing.
`6ba6f8d633`
**Artifact URLs carried no origin.** `artifactUrl()` returned a bare
path while every other call in that service goes through `http()` with
`baseURL: store.state.API_ENDPOINT`. Artifact URLs go to `<img src>` and
`fetch()` directly, so in dev they resolved against Vite on `:8081`,
which has no `/api` route and no proxy — every screenshot and evidence
bundle 404'd. Verified against a running backend with a real key:
`:8081` → 404, `:5080` → 200. Production, served from the API's own
origin, is unchanged.
`baa7407cb5`
**The locator ladder was invisible.** `candidates_tried` lists only the
rungs the probe stood on, so a step that matched on its primary looked
identical to a step that had no second candidate — both one row, both a
grey `used_as_primary` badge. On live run `3HGDMrlU` the failing assert
had one authored locator and every `assert`/`fill` step in that journey
is built the same way, while `click` steps average 2.4 candidates. The
section now leads with "N of M tried", names a one-rung ladder as having
no fallback, labels outcomes in words instead of enum values, and dims
unreached rungs the way the timeline dims a skipped step.
`c05e1ce133`
## Test plan
- `npx vitest run src/views/synthetics src/components/synthetics/results
src/components/synthetics/StepEvidence.spec.ts
src/composables/synthetics src/composables/useSyntheticEvidence.spec.ts
src/services/synthetics.spec.ts` → 422 passing
- `npm run type-check` → clean
- `npm run lint:design:strict` → 2 pre-existing regressions above (see
Known failing)
- Behaviour on the run-detail changes was verified against live records
from `OpenObserve Sys Query` and `OpenObserve Cloud Happy Path` rather
than fixtures alone
## Not covered
- The "Add a fallback locator" action from the locator-ladder work needs
a step-level deep link; `synthetics/edit/:id` has no step anchor, so the
warning states the finding without an action.
- Browser-level confirmation of the artifact-URL and locator-ladder
changes is outstanding — the local backend's search service was
returning `tcp connect error` during verification, so those two were
confirmed against records and by unit test rather than in a running
page.
This commit is contained in:
parent
073c5c2680
commit
bbed40d8f4
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,18 @@
|
|||
// 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/>.
|
||||
//
|
||||
// Validation schema for the "Create Token" dialog in SyntheticsTokens.vue. Name
|
||||
// is required + max 256; "default" is reserved (the default token is replaced via
|
||||
// Rotate, not created). No description field for synthetics tokens.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect } from "vitest";
|
||||
import { makeSyntheticsLocationFormSchema } from "./SyntheticsLocationsList.schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
// 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/>.
|
||||
//
|
||||
// Validation schema for SyntheticsLocationForm.vue. `provider`, `region`, and
|
||||
// `label` are required. When provider is "custom", `customProvider` becomes
|
||||
// required. `enabled` defaults to true. Built via a factory so the required
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,19 @@
|
|||
// Copyright 2026 OpenObserve Inc.
|
||||
<!-- 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/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<ODrawer
|
||||
:open="open"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,19 @@
|
|||
// Copyright 2026 OpenObserve Inc.
|
||||
<!-- 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/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<OTable
|
||||
v-model:selected-ids="localSelectedIds"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, beforeEach } from "vitest";
|
||||
import { mount, VueWrapper } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { useI18n } from "vue-i18n";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import EmptyStateActionCard from "@/lib/core/EmptyState/EmptyStateActionCard.vue";
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@ function issuePaths(result: { success: boolean; error?: { issues: { path: Proper
|
|||
describe("makeBrowserCheckSaveSchema journey validation", () => {
|
||||
const schema = makeBrowserCheckSaveSchema(t);
|
||||
|
||||
it("should accept a v1 journey whose steps carry selectors", () => {
|
||||
// A bare `selector` is the version-1 channel, and version 1 is gone. The
|
||||
// element has to be named by the locator bundle, because that is the only
|
||||
// thing buildV2Steps puts on the wire — a gate that accepted `selector`
|
||||
// would wave through a journey the server then answers with a 400.
|
||||
it("should reject a step whose only target is a version-1 selector", () => {
|
||||
const result = schema.safeParse(
|
||||
form([
|
||||
{ id: "1", action: "navigate", value: "https://app.test" },
|
||||
|
|
@ -46,7 +50,8 @@ describe("makeBrowserCheckSaveSchema journey validation", () => {
|
|||
]),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.success).toBe(false);
|
||||
expect(issuePaths(result)).toContain("journey.1.selector");
|
||||
});
|
||||
|
||||
// Regression: a check saved as steps_version 2 comes back with no `selector`
|
||||
|
|
@ -67,7 +72,6 @@ describe("makeBrowserCheckSaveSchema journey validation", () => {
|
|||
value: 'internal:testid=[data-test="login-as-internal-user"]',
|
||||
},
|
||||
],
|
||||
user_override: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
|
@ -77,14 +81,17 @@ describe("makeBrowserCheckSaveSchema journey validation", () => {
|
|||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept a v2 step whose only target is a pinned override", () => {
|
||||
it("should accept a step whose target is a locator the author wrote", () => {
|
||||
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" } },
|
||||
locator: {
|
||||
candidates: [{ kind: "css", value: "#login", origin: "authored" }],
|
||||
author_ordered: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
|
@ -123,19 +130,82 @@ describe("makeBrowserCheckSaveSchema journey validation", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Version 1 is retired, so the save gate is the only thing standing between an
|
||||
// unsaveable journey and a 400 from the server. It used to be silent: an
|
||||
// unsaveable journey simply fell back to the version-1 payload shape.
|
||||
describe("makeBrowserCheckSaveSchema version-2 save gate", () => {
|
||||
const bundle = { candidates: [{ kind: "css", value: "#go" }] };
|
||||
const opened = { id: "1", action: "navigate", value: "https://app.test" };
|
||||
|
||||
it("should reject a journey containing a retired action, naming the step", () => {
|
||||
const schema = makeBrowserCheckSaveSchema(t);
|
||||
const result = schema.safeParse(
|
||||
form([opened, { id: "2", action: "wait", name: "Pause", value: "2000" }]),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(issuePaths(result)).toContain("journey.1.action");
|
||||
});
|
||||
|
||||
it("should name the step and the action in the retired-action message", () => {
|
||||
const seen: Record<string, unknown>[] = [];
|
||||
const spy = (key: string, params?: Record<string, unknown>) => {
|
||||
if (params) seen.push(params);
|
||||
return key;
|
||||
};
|
||||
makeBrowserCheckSaveSchema(spy).safeParse(
|
||||
form([opened, { id: "2", action: "hover", name: "Reveal menu" }]),
|
||||
);
|
||||
|
||||
expect(seen).toContainEqual({ step: "Reveal menu", action: "hover" });
|
||||
});
|
||||
|
||||
it("should reject an element step whose locator bundle is empty", () => {
|
||||
const schema = makeBrowserCheckSaveSchema(t);
|
||||
const result = schema.safeParse(
|
||||
form([opened, { id: "2", action: "click", name: "Sign in", locator: { candidates: [] } }]),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(issuePaths(result)).toContain("journey.1.selector");
|
||||
});
|
||||
|
||||
it("should name the step in the missing-target message", () => {
|
||||
const seen: Record<string, unknown>[] = [];
|
||||
const spy = (key: string, params?: Record<string, unknown>) => {
|
||||
if (params) seen.push(params);
|
||||
return key;
|
||||
};
|
||||
makeBrowserCheckSaveSchema(spy).safeParse(
|
||||
form([opened, { id: "2", action: "click", name: "Sign in", locator: { candidates: [] } }]),
|
||||
);
|
||||
|
||||
expect(seen).toContainEqual({ step: "Sign in" });
|
||||
});
|
||||
|
||||
it("should accept a journey every step of which can be built as version 2", () => {
|
||||
const schema = makeBrowserCheckSaveSchema(t);
|
||||
const result = schema.safeParse(
|
||||
form([opened, { id: "2", action: "click", name: "Sign in", locator: bundle }]),
|
||||
);
|
||||
|
||||
expect(issuePaths(result)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// 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" } };
|
||||
const bundle = { candidates: [{ 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 },
|
||||
{ id: "2", action: "click", name: "", locator: bundle },
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
@ -147,7 +217,7 @@ describe("makeBrowserCheckSaveSchema step name", () => {
|
|||
const result = schema.safeParse(
|
||||
form([
|
||||
{ id: "1", action: "navigate", value: "https://app.test" },
|
||||
{ id: "2", action: "click", name: " ", locator: pin },
|
||||
{ id: "2", action: "click", name: " ", locator: bundle },
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
@ -189,7 +259,7 @@ describe("makeBrowserCheckSaveSchema step name", () => {
|
|||
// 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 bundle = { candidates: [{ 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)", () => {
|
||||
|
|
@ -215,7 +285,7 @@ describe("makeBrowserCheckSaveSchema field-level step rules", () => {
|
|||
// 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 }]),
|
||||
form([opened, { id: "2", action: "type", value: "", locator: bundle }]),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
|
@ -224,7 +294,7 @@ describe("makeBrowserCheckSaveSchema field-level step rules", () => {
|
|||
|
||||
it("should accept a type step that has text", () => {
|
||||
const result = schema.safeParse(
|
||||
form([opened, { id: "2", action: "type", value: "hunter2", locator: pin }]),
|
||||
form([opened, { id: "2", action: "type", value: "hunter2", locator: bundle }]),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
|
@ -237,7 +307,7 @@ describe("makeBrowserCheckSaveSchema field-level step rules", () => {
|
|||
{
|
||||
id: "2",
|
||||
action: "assert",
|
||||
locator: pin,
|
||||
locator: bundle,
|
||||
assertion: { kind: "element_text", expected: "" },
|
||||
},
|
||||
]),
|
||||
|
|
@ -251,7 +321,7 @@ describe("makeBrowserCheckSaveSchema field-level step rules", () => {
|
|||
const result = schema.safeParse(
|
||||
form([
|
||||
opened,
|
||||
{ id: "2", action: "assert", locator: pin, assertion: { kind: "element_visible" } },
|
||||
{ id: "2", action: "assert", locator: bundle, assertion: { kind: "element_visible" } },
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
// 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/>.
|
||||
//
|
||||
// Validation schema for CreateBrowserTest.vue (create/edit browser check).
|
||||
// Built via a factory so error messages stay i18n-driven (pass useI18n's `t`).
|
||||
//
|
||||
|
|
@ -9,17 +22,34 @@
|
|||
|
||||
import { z } from "zod";
|
||||
import { stepIsMissingTarget } from "@/utils/synthetics/stepTarget";
|
||||
import { isStorableAction } from "@/utils/synthetics/buildV2Steps";
|
||||
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() });
|
||||
/**
|
||||
* Message factory. Params are passed through to vue-i18n so a failure can name
|
||||
* the step it is about — "Selector is required" on a twenty-step journey is a
|
||||
* scavenger hunt.
|
||||
*/
|
||||
type Translate = (_key: string, _params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The locator bundle, as it sits on an editor step. */
|
||||
const compositePartSchema = z.object({ value: z.string(), relation: z.string().optional() });
|
||||
const locatorCandidateSchema = z.object({
|
||||
kind: z.string(),
|
||||
value: z.string(),
|
||||
origin: z.string().optional(),
|
||||
from: z.array(compositePartSchema).optional(),
|
||||
});
|
||||
// Declared, not tolerated: z.object strips what it does not declare, so an
|
||||
// undeclared `origin` would be dropped here and the payload built from the
|
||||
// parsed value would carry none.
|
||||
const locatorSchema = z.object({
|
||||
candidates: z.array(locatorCandidateSchema).nullish(),
|
||||
user_override: locatorCandidateSchema.nullish(),
|
||||
author_ordered: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const makeBrowserCheckGateSchema = (t: (_key: string) => string) =>
|
||||
export const makeBrowserCheckGateSchema = (t: Translate) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t("synthetics.validation.nameRequired")).trim(),
|
||||
url: z
|
||||
|
|
@ -45,7 +75,7 @@ export const browserCheckGateDefaults = (): BrowserCheckGateForm => ({
|
|||
url: "",
|
||||
});
|
||||
|
||||
export const makeBrowserCheckSaveSchema = (t: (_key: string) => string) =>
|
||||
export const makeBrowserCheckSaveSchema = (t: Translate) =>
|
||||
z
|
||||
.object({
|
||||
name: z.string().min(1, t("synthetics.validation.nameRequired")).trim(),
|
||||
|
|
@ -73,15 +103,12 @@ export const makeBrowserCheckSaveSchema = (t: (_key: string) => string) =>
|
|||
// 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.
|
||||
// A step names its element here, and nowhere else. Declared
|
||||
// explicitly because z.object strips what it does not declare —
|
||||
// leaving it out made every 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.
|
||||
|
|
@ -105,13 +132,30 @@ export const makeBrowserCheckSaveSchema = (t: (_key: string) => string) =>
|
|||
});
|
||||
}
|
||||
|
||||
// Every element-acting step must name its element, by either channel.
|
||||
// The two rules that decide whether a journey can be stored at all.
|
||||
// They used to be answered by isV2Journey, whose only consequence was a
|
||||
// quiet fall back to the version-1 payload shape — which discarded every
|
||||
// locator bundle the recorder had captured. Version 1 is gone, so the
|
||||
// answer has to reach the author, on the step it is about.
|
||||
for (let i = 0; i < val.journey.length; i++) {
|
||||
if (stepIsMissingTarget(val.journey[i])) {
|
||||
const step = val.journey[i];
|
||||
|
||||
if (!isStorableAction(step.action)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["journey", i, "action"],
|
||||
message: t("synthetics.validation.retiredAction", {
|
||||
step: step.name,
|
||||
action: step.action,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (stepIsMissingTarget(step)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["journey", i, "selector"],
|
||||
message: t("synthetics.validation.selectorRequired"),
|
||||
message: t("synthetics.validation.selectorRequired", { step: step.name }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { useI18n } from "vue-i18n";
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { mount } from "@vue/test-utils";
|
||||
|
|
@ -197,3 +210,75 @@ describe("StepEvidence — what the application did (Phase 6)", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Locator ladder ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// From the live "OpenObserve Cloud Happy Path" failure: step 16 asserted a
|
||||
// single role locator and reported `used_as_primary`. The panel showed one grey
|
||||
// `used_as_primary` badge and no way to tell whether a fallback had been tried,
|
||||
// skipped, or never existed.
|
||||
describe("StepEvidence locator ladder", () => {
|
||||
const mountWith = (candidatesTried: any[]) =>
|
||||
mount(StepEvidence, {
|
||||
props: { detail: detail({ candidatesTried }) },
|
||||
global: { plugins: [i18n] },
|
||||
});
|
||||
|
||||
it("states how much of the ladder was walked", () => {
|
||||
const w = mountWith([
|
||||
{ kind: "role", value: "a", outcome: "matched" },
|
||||
{ kind: "css", value: "b", outcome: "not_tried" },
|
||||
{ kind: "text", value: "c", outcome: "not_tried" },
|
||||
]);
|
||||
expect(w.find('[data-test="synthetics-run-detail-locator-count"]').text()).toContain("1 of 3");
|
||||
});
|
||||
|
||||
it("names a one-rung ladder as having no fallback", () => {
|
||||
// The whole question the old UI could not answer.
|
||||
const w = mountWith([{ kind: "role", value: "a", outcome: "used_as_primary" }]);
|
||||
expect(w.find('[data-test="synthetics-run-detail-locator-no-fallback"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="synthetics-run-detail-locator-no-fallback"]').text()).toContain(
|
||||
"no fallback",
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing about fallbacks when the step had several", () => {
|
||||
const w = mountWith([
|
||||
{ kind: "role", value: "a", outcome: "matched" },
|
||||
{ kind: "css", value: "b", outcome: "not_tried" },
|
||||
]);
|
||||
expect(w.find('[data-test="synthetics-run-detail-locator-no-fallback"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces the raw enum with words a reader can act on", () => {
|
||||
const w = mountWith([
|
||||
{ kind: "role", value: "a", outcome: "used_as_primary" },
|
||||
{ kind: "css", value: "b", outcome: "not_tried" },
|
||||
]);
|
||||
const text = w.find('[data-test="synthetics-run-detail-locator-resolution"]').text();
|
||||
expect(text).toContain("used directly");
|
||||
expect(text).toContain("not reached");
|
||||
expect(text).not.toContain("used_as_primary");
|
||||
expect(text).not.toContain("not_tried");
|
||||
});
|
||||
|
||||
it("dims the rungs the probe never reached", () => {
|
||||
// Same treatment a skipped step gets in the timeline.
|
||||
const w = mountWith([
|
||||
{ kind: "role", value: "a", outcome: "matched" },
|
||||
{ kind: "css", value: "b", outcome: "not_tried" },
|
||||
]);
|
||||
const rows = w.findAll('[data-test="synthetics-run-detail-locator-candidate"]');
|
||||
expect(rows[0].classes().join(" ")).not.toContain("opacity-50");
|
||||
expect(rows[1].classes().join(" ")).toContain("opacity-50");
|
||||
});
|
||||
|
||||
it("still reports a ladder where nothing matched", () => {
|
||||
const w = mountWith([
|
||||
{ kind: "role", value: "a", outcome: "not_found" },
|
||||
{ kind: "css", value: "b", 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-count"]').text()).toContain("2 of 2");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
/**
|
||||
* What the runner saw when a step failed (spec P5.4, items 3–5).
|
||||
*
|
||||
|
|
@ -91,15 +106,71 @@ function outcomeVariant(outcome: string): "success" | "error" | "default" {
|
|||
if (outcome === "not_found") return "error";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw enum values are the probe's vocabulary, not the reader's. `used_as_primary`
|
||||
* in a neutral badge said nothing about whether a fallback existed, which is the
|
||||
* one question a failed locator raises.
|
||||
*/
|
||||
const OUTCOME_LABEL: Record<string, string> = {
|
||||
matched: "synthetics.runDetail.locatorOutcomeMatched",
|
||||
not_found: "synthetics.runDetail.locatorOutcomeNotFound",
|
||||
used_as_primary: "synthetics.runDetail.locatorOutcomePrimary",
|
||||
not_tried: "synthetics.runDetail.locatorOutcomeNotTried",
|
||||
};
|
||||
|
||||
function outcomeLabel(outcome: string): string {
|
||||
const key = OUTCOME_LABEL[outcome];
|
||||
// An outcome this UI does not know about is shown verbatim rather than
|
||||
// dropped — a new probe value must not render as a blank badge.
|
||||
return key ? t(key) : outcome;
|
||||
}
|
||||
|
||||
/** How far down the ladder the probe actually got. */
|
||||
const triedCount = computed(() => candidates.value.filter((c) => c.outcome !== "not_tried").length);
|
||||
|
||||
/**
|
||||
* One authored locator and no second rung.
|
||||
*
|
||||
* Distinct from "every candidate failed": there was nothing to fall back TO,
|
||||
* which is a property of how the step was recorded rather than of the page. On
|
||||
* the run that prompted this, every assert step in the journey was built this
|
||||
* way, and the panel had no way to say so.
|
||||
*/
|
||||
const noFallback = computed(() => candidates.value.length === 1);
|
||||
</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>
|
||||
<!-- The count answers "was a fallback tried?" before any row is read:
|
||||
"1 of 3 tried" and "1 of 1 tried" are different findings that used to
|
||||
render as the same single row. -->
|
||||
<div class="mb-1 flex items-baseline gap-2">
|
||||
<h4 class="text-text-heading m-0 text-xs font-semibold">
|
||||
{{ t("synthetics.runDetail.locatorResolution") }}
|
||||
</h4>
|
||||
<span class="text-text-secondary text-2xs" data-test="synthetics-run-detail-locator-count">
|
||||
{{
|
||||
t("synthetics.runDetail.locatorTriedOf", {
|
||||
tried: triedCount,
|
||||
total: candidates.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<!-- A step with one locator did not "fail to heal" — it had nothing to
|
||||
heal with. That is a recording property, and it is actionable in a way
|
||||
the outcome badge never was. -->
|
||||
<p
|
||||
v-if="noFallback"
|
||||
class="text-status-warning-text m-0 mb-1 flex items-start gap-1 text-xs"
|
||||
data-test="synthetics-run-detail-locator-no-fallback"
|
||||
>
|
||||
<OIcon name="warning" size="xs" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t("synthetics.runDetail.locatorNoFallback") }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="noneMatched"
|
||||
class="text-text-secondary m-0 mb-1 text-xs"
|
||||
|
|
@ -115,12 +186,19 @@ function outcomeVariant(outcome: string): "success" | "error" | "default" {
|
|||
{{ t("synthetics.runDetail.locatorHealed") }}
|
||||
</p>
|
||||
<ul class="m-0 flex list-none flex-col gap-1 p-0">
|
||||
<!-- Untried rungs are dimmed, not hidden: the same treatment a skipped
|
||||
step gets in the timeline. Hiding them would restore exactly the
|
||||
ambiguity this section exists to remove. -->
|
||||
<li
|
||||
v-for="(c, i) in candidates"
|
||||
:key="`${c.kind}-${i}`"
|
||||
class="flex items-center gap-2 text-xs"
|
||||
:class="c.outcome === 'not_tried' ? 'opacity-50' : ''"
|
||||
data-test="synthetics-run-detail-locator-candidate"
|
||||
>
|
||||
<OBadge :variant="outcomeVariant(c.outcome)" size="sm">{{ c.outcome }}</OBadge>
|
||||
<OBadge :variant="outcomeVariant(c.outcome)" size="sm">{{
|
||||
outcomeLabel(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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, VueWrapper } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useStore } from "vuex";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrowserCheck } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrowserCheck, SyntheticsDevice } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrowserCheck } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrowserCheck, SyntheticsFolder } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrowserCheck, SyntheticsLocation } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrowserCheck } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, vi, afterEach } from "vitest";
|
||||
import { mount, VueWrapper } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrowserCheck } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrowserCheck, BrowserCheckSchedule } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// HTTP request + assertions card. Edits `check.http` and re-emits the whole
|
||||
// check — same update:check contract as the other configure sections.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ProtocolCheck, SshCheckConfig } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ProtocolCheck, TcpCheckConfig } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ProtocolCheck, TlsCheckConfig } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
@ -225,7 +238,6 @@ describe("BrowserJourney recording", () => {
|
|||
id: "s1",
|
||||
action: "click",
|
||||
name: "Sign in",
|
||||
code: "",
|
||||
locator: { candidates: [{ kind: "test_attribute", value: '[data-test="sign-in"]' }] },
|
||||
settle: {
|
||||
navigation: { url_pattern: "**/web/**" },
|
||||
|
|
@ -270,7 +282,6 @@ describe("BrowserJourney recording", () => {
|
|||
action: "navigate",
|
||||
name: "Open page",
|
||||
value: "https://old.test",
|
||||
code: "",
|
||||
wire: { id: "w1", action: "navigate", url: "https://old.test" },
|
||||
};
|
||||
|
||||
|
|
@ -291,7 +302,7 @@ describe("BrowserJourney recording", () => {
|
|||
|
||||
it("should emit clear-results when modelValue becomes empty", async () => {
|
||||
wrapper = mountJourney({
|
||||
modelValue: [{ id: "s1", action: "click", name: "Step 1", code: "" }],
|
||||
modelValue: [{ id: "s1", action: "click", name: "Step 1" }],
|
||||
});
|
||||
|
||||
// Clearing all steps should trigger the length watcher to emit clear-results
|
||||
|
|
@ -315,13 +326,15 @@ describe("BrowserJourney step validation", () => {
|
|||
return (wrapper.vm as any).validateStepSelectors();
|
||||
}
|
||||
|
||||
it("should pass a v1 journey whose steps carry selectors", () => {
|
||||
// The version-1 channel. Only the locator bundle reaches the wire now, so a
|
||||
// step whose element lives in `selector` alone would be posted target-less.
|
||||
it("should fail a step whose only target is a version-1 selector", () => {
|
||||
expect(
|
||||
validate([
|
||||
{ id: "1", action: "navigate", value: "https://app.test" },
|
||||
{ id: "2", action: "click", selector: "#login" },
|
||||
]),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Regression: a v2 step identifies its element with a locator bundle and has
|
||||
|
|
@ -336,21 +349,23 @@ describe("BrowserJourney step validation", () => {
|
|||
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", () => {
|
||||
it("should pass a step whose only target is a locator the author wrote", () => {
|
||||
expect(
|
||||
validate([
|
||||
{ id: "1", action: "navigate", value: "https://app.test" },
|
||||
{
|
||||
id: "2",
|
||||
action: "click",
|
||||
locator: { candidates: [], user_override: { kind: "css", value: "#login" } },
|
||||
locator: {
|
||||
candidates: [{ kind: "css", value: "#login", origin: "authored" }],
|
||||
author_ordered: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
|
|
@ -414,7 +429,6 @@ describe("BrowserJourney step creation", () => {
|
|||
action: "navigate",
|
||||
name: "Open app",
|
||||
value: "https://app.test",
|
||||
code: "",
|
||||
};
|
||||
wrapper = mountJourney({ modelValue: [existing] });
|
||||
|
||||
|
|
@ -447,7 +461,7 @@ describe("BrowserJourney step creation is version 2", () => {
|
|||
|
||||
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].locator).toEqual({ candidates: [] });
|
||||
expect(steps[0].selector).toBeUndefined();
|
||||
expect(steps[0].selectorType).toBeUndefined();
|
||||
});
|
||||
|
|
@ -465,12 +479,11 @@ describe("BrowserJourney per-step failure evidence", () => {
|
|||
});
|
||||
|
||||
const journey = [
|
||||
{ id: "s1", action: "navigate", name: "Open app", value: "https://app.test", code: "" },
|
||||
{ id: "s1", action: "navigate", name: "Open app", value: "https://app.test" },
|
||||
{
|
||||
id: "s2",
|
||||
action: "click",
|
||||
name: "Sign in",
|
||||
code: "",
|
||||
locator: { candidates: [{ kind: "css", value: "#go" }] },
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrowserStep, ReplayPhase, StepReplayResult } from "@/types/synthetics";
|
||||
|
|
@ -14,7 +29,6 @@ import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
|
|||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import JourneySteps from "./JourneySteps.vue";
|
||||
import UpgradeJourneyBanner from "./UpgradeJourneyBanner.vue";
|
||||
import ZeroAssertionNotice from "./ZeroAssertionNotice.vue";
|
||||
import TestIdMisconfiguredNotice from "./TestIdMisconfiguredNotice.vue";
|
||||
import { DEFAULT_TEST_ID_ATTR } from "@/constants/synthetics";
|
||||
|
|
@ -490,12 +504,11 @@ function handleInsertBelow(row: BrowserStep) {
|
|||
id: getUUIDv7(true),
|
||||
action: "click",
|
||||
name: "",
|
||||
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 },
|
||||
locator: { candidates: [] },
|
||||
});
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
|
@ -515,9 +528,8 @@ function addStep() {
|
|||
id: getUUIDv7(true),
|
||||
action: "click",
|
||||
name: "",
|
||||
code: "",
|
||||
// See handleInsertBelow — a new step is version 2.
|
||||
locator: { candidates: [], user_override: null },
|
||||
locator: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
|
@ -672,15 +684,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { mount } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
/**
|
||||
* The assertion editor for an `assert` step.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
// 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 { mount } from "@vue/test-utils";
|
||||
import { mount, VueWrapper } 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";
|
||||
|
||||
// The real message catalogue, not a stub: a missing key then fails the test
|
||||
// rather than rendering as its own name.
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: "en-US",
|
||||
|
|
@ -14,10 +29,13 @@ const i18n = createI18n({
|
|||
messages: { "en-US": en as Record<string, unknown> },
|
||||
});
|
||||
|
||||
const TESTID = '[data-test="login-sign-in"]';
|
||||
const ROLE = 'role=button[name="Sign In"]';
|
||||
|
||||
const BUNDLE: StepLocator = {
|
||||
candidates: [
|
||||
{ kind: "test_attribute", value: '[data-test="login-sign-in"]' },
|
||||
{ kind: "role", value: 'role=button[name="Sign In"]' },
|
||||
{ kind: "test_attribute", value: TESTID },
|
||||
{ kind: "role", value: ROLE },
|
||||
{ kind: "css", value: ".btn-primary" },
|
||||
],
|
||||
};
|
||||
|
|
@ -31,170 +49,67 @@ function render(locator: StepLocator = BUNDLE) {
|
|||
|
||||
const test = (name: string) => `[data-test="${name}"]`;
|
||||
|
||||
/** `data-test` sits on the OInput root, so reach the inner <input>. */
|
||||
const ownInput = (wrapper: VueWrapper) =>
|
||||
wrapper.find(`${test("synthetics-journey-step-locator-override-input")} input`);
|
||||
|
||||
/** The last `update:locator` payload, which is what would be saved. */
|
||||
function emitted(wrapper: VueWrapper): StepLocator {
|
||||
const events = wrapper.emitted("update:locator") as StepLocator[][] | undefined;
|
||||
expect(events, "no update:locator was emitted").toBeTruthy();
|
||||
return events![events!.length - 1][0];
|
||||
}
|
||||
|
||||
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");
|
||||
it("renders every candidate as a row, in stored order", () => {
|
||||
const rows = render().findAll(test("synthetics-journey-step-locator-row"));
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0].text()).toContain(TESTID);
|
||||
expect(rows[0].text()).toContain("Test attribute");
|
||||
expect(rows[2].text()).toContain(".btn-primary");
|
||||
});
|
||||
|
||||
// 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");
|
||||
// Ordering IS the feature. It must not need discovering, so this is permanent
|
||||
// helper text rather than a tooltip.
|
||||
it("says how the list is used, without being asked", () => {
|
||||
expect(render().text()).toContain("Tried in order, top first");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("labels where each candidate came from", () => {
|
||||
const wrapper = render({
|
||||
...BUNDLE,
|
||||
user_override: { kind: "css", value: "#pinned" },
|
||||
candidates: [
|
||||
{ kind: "css", value: "#mine", origin: "authored" },
|
||||
{ kind: "test_attribute", value: TESTID },
|
||||
],
|
||||
});
|
||||
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);
|
||||
const rows = wrapper.findAll("tbody tr");
|
||||
expect(rows[0].text()).toContain("Yours");
|
||||
// Absent origin means recorded — the shape every pre-provenance bundle has.
|
||||
expect(rows[1].text()).toContain("Recorded");
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// The reorder is the replacement for pinning. A pin was exclusive: the only way
|
||||
// to say "prefer this one" was to turn fallback off entirely.
|
||||
describe("reordering", () => {
|
||||
it("emits the new order and marks the list author-owned", 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 reversed = [...BUNDLE.candidates].reverse();
|
||||
|
||||
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);
|
||||
});
|
||||
await wrapper.findComponent({ name: "OTable" }).vm.$emit("row-reorder", reversed);
|
||||
|
||||
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);
|
||||
const next = emitted(wrapper);
|
||||
expect(next.candidates.map((c) => c.value)).toEqual(reversed.map((c) => c.value));
|
||||
// H1: healing must never reorder an author-owned list, and the flag is set
|
||||
// by the act rather than by a control nobody would think to touch.
|
||||
expect(next.author_ordered).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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`);
|
||||
describe("adding your own locator", () => {
|
||||
async function addOwn(wrapper: VueWrapper, value: string) {
|
||||
await ownInput(wrapper).setValue(value);
|
||||
await wrapper.find(test("synthetics-journey-step-locator-add")).trigger("click");
|
||||
}
|
||||
|
||||
it("labels the input as the primary control, not an override", () => {
|
||||
|
|
@ -231,59 +146,200 @@ describe("BrowserJourneyLocator empty bundle", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps the override label, unmarked, when candidates exist", () => {
|
||||
it("appends it, with a kind read from the value", async () => {
|
||||
const wrapper = render();
|
||||
expect(wrapper.text()).toContain("Use a different locator");
|
||||
expect(wrapper.findComponent({ name: "OInput" }).props("required")).toBe(false);
|
||||
});
|
||||
});
|
||||
await addOwn(wrapper, 'internal:role=button[name="Go"i]');
|
||||
|
||||
// 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({
|
||||
const next = emitted(wrapper);
|
||||
expect(next.candidates).toHaveLength(4);
|
||||
expect(next.candidates[3]).toEqual({
|
||||
kind: "role",
|
||||
value: 'internal:role=button[name="Sign In"i]',
|
||||
value: 'internal:role=button[name="Go"i]',
|
||||
origin: "authored",
|
||||
});
|
||||
expect(next.author_ordered).toBe(true);
|
||||
});
|
||||
|
||||
it("still stores css for a bare attribute selector", async () => {
|
||||
// `row-key="value"` needs values to stay unique. Nothing could add a
|
||||
// candidate before this phase, so nothing checked — and a duplicate breaks
|
||||
// table selection silently rather than loudly.
|
||||
it("refuses a duplicate rather than breaking row identity", 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");
|
||||
await addOwn(wrapper, TESTID);
|
||||
|
||||
expect(wrapper.emitted("update:locator")).toBeFalsy();
|
||||
expect(wrapper.find(test("synthetics-journey-step-locator-error")).text()).toContain(
|
||||
"already in the list",
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it("prefills from a row without editing the recorded value in place", async () => {
|
||||
const wrapper = render();
|
||||
await wrapper
|
||||
.find(test("synthetics-journey-step-locator-start-from-primary-btn"))
|
||||
.findAll(test("synthetics-journey-step-locator-start-from-btn"))[1]
|
||||
.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");
|
||||
|
||||
expect((ownInput(wrapper).element as HTMLInputElement).value).toBe(ROLE);
|
||||
// Copying, not editing: the stored list is what a later healing pass
|
||||
// compares against, and an author's correction is not evidence.
|
||||
expect(wrapper.emitted("update:locator")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleting", () => {
|
||||
it("removes the row and keeps the rest in order", async () => {
|
||||
const wrapper = render();
|
||||
await wrapper.findAll(test("synthetics-journey-step-locator-delete-btn"))[0].trigger("click");
|
||||
|
||||
expect(emitted(wrapper).candidates.map((c) => c.value)).toEqual([ROLE, ".btn-primary"]);
|
||||
});
|
||||
|
||||
// "Use only this one" is expressed by deleting the others — right up to the
|
||||
// point where the step would name no element at all, which the save gate
|
||||
// refuses and this block cannot recover from.
|
||||
it("refuses to delete the last one", async () => {
|
||||
const wrapper = render({ candidates: [{ kind: "css", value: "#only" }] });
|
||||
await wrapper.find(test("synthetics-journey-step-locator-delete-btn")).trigger("click");
|
||||
|
||||
expect(wrapper.emitted("update:locator")).toBeFalsy();
|
||||
expect(wrapper.find(test("synthetics-journey-step-locator-error")).text()).toContain(
|
||||
"at least one locator",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Per row, not per block. The old whole-block notice fired only when EVERY
|
||||
// candidate was positional — that is, only when nothing could be done about it
|
||||
// — and stayed silent when something could.
|
||||
describe("per-row warnings", () => {
|
||||
it("warns on each positional row, not only when all of them are", () => {
|
||||
const wrapper = render({
|
||||
candidates: [
|
||||
{ kind: "test_attribute", value: '[data-test="row"] >> nth=1' },
|
||||
{ kind: "role", value: ROLE },
|
||||
{ kind: "css", value: "body > div:nth-child(3)" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(wrapper.findAll(test("synthetics-journey-step-locator-row-positional"))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("warns on an id the component library mints per render", () => {
|
||||
const wrapper = render({
|
||||
candidates: [
|
||||
{ kind: "css", value: "#reka-popover-trigger-v-21" },
|
||||
{ kind: "css", value: "#main-content" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(wrapper.findAll(test("synthetics-journey-step-locator-row-generated-id"))).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// What the block is responsible for: opening the builder with the right rows,
|
||||
// and placing what comes back. The builder's own behaviour — the relation
|
||||
// picker, the position toggle, the result string — is pinned in
|
||||
// LocatorComposeDialog.spec.ts, because ODialog teleports its content out of
|
||||
// this wrapper's tree and asserting through it would test the teleport.
|
||||
describe("combining", () => {
|
||||
const dialogOf = (wrapper: VueWrapper) => wrapper.findComponent({ name: "LocatorComposeDialog" });
|
||||
|
||||
async function select(wrapper: VueWrapper, values: string[]) {
|
||||
await wrapper.findComponent({ name: "OTable" }).vm.$emit("update:selectedIds", values);
|
||||
await wrapper.vm.$nextTick();
|
||||
}
|
||||
|
||||
it("stays out of reach until two rows are selected", async () => {
|
||||
const wrapper = render();
|
||||
expect(wrapper.find(test("synthetics-journey-step-locator-combine")).exists()).toBe(false);
|
||||
|
||||
await select(wrapper, [TESTID]);
|
||||
expect(wrapper.find(test("synthetics-journey-step-locator-combine")).exists()).toBe(false);
|
||||
|
||||
await select(wrapper, [TESTID, ROLE]);
|
||||
expect(wrapper.find(test("synthetics-journey-step-locator-combine")).exists()).toBe(true);
|
||||
});
|
||||
|
||||
async function openDialog(wrapper: VueWrapper, values: string[]) {
|
||||
await select(wrapper, values);
|
||||
await wrapper.find(test("synthetics-journey-step-locator-combine")).trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
}
|
||||
|
||||
// `and` is provably safe between recorded candidates: Playwright verified each
|
||||
// against the same target, so intersecting them gives that target. Asking here
|
||||
// would add a click and teach nothing.
|
||||
it("opens with the selected rows and the recorded values to reason from", async () => {
|
||||
const wrapper = render();
|
||||
await openDialog(wrapper, [TESTID, ROLE]);
|
||||
|
||||
const dialog = dialogOf(wrapper);
|
||||
expect(dialog.props("open")).toBe(true);
|
||||
// The proof the builder needs: every recorded value in the bundle. It uses
|
||||
// it to decide whether `and` is provably safe and the picker can be hidden.
|
||||
expect(dialog.props("recorded")).toEqual([TESTID, ROLE, ".btn-primary"]);
|
||||
expect((dialog.props("parts") as { value: string }[]).map((p) => p.value)).toEqual([
|
||||
TESTID,
|
||||
ROLE,
|
||||
]);
|
||||
});
|
||||
|
||||
// The guarantee ends the moment a locator the author typed joins in, and the
|
||||
// editor has no live DOM with which to notice a wrong choice.
|
||||
it("hands the builder only the recorded values, so it can tell them apart", async () => {
|
||||
const wrapper = render({
|
||||
candidates: [
|
||||
{ kind: "test_attribute", value: TESTID },
|
||||
{ kind: "css", value: "#mine", origin: "authored" },
|
||||
],
|
||||
});
|
||||
await openDialog(wrapper, [TESTID, "#mine"]);
|
||||
|
||||
// "#mine" is absent, so canDefaultToAnd is false and the builder asks.
|
||||
expect(dialogOf(wrapper).props("recorded")).toEqual([TESTID]);
|
||||
});
|
||||
|
||||
it("passes the selected rows in LIST order, not selection order", async () => {
|
||||
const wrapper = render();
|
||||
// Selected bottom-up; the base part must still be the higher row.
|
||||
await openDialog(wrapper, [".btn-primary", TESTID]);
|
||||
|
||||
expect((dialogOf(wrapper).props("parts") as { value: string }[]).map((p) => p.value)).toEqual([
|
||||
TESTID,
|
||||
".btn-primary",
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends the combination with its parts and how they were joined", async () => {
|
||||
const wrapper = render();
|
||||
await openDialog(wrapper, [TESTID, ROLE]);
|
||||
await wrapper.findComponent({ name: "LocatorComposeDialog" }).vm.$emit("combine", {
|
||||
value: `${TESTID} >> internal:and=${JSON.stringify(ROLE)}`,
|
||||
from: [{ value: TESTID }, { relation: "and", value: ROLE }],
|
||||
});
|
||||
|
||||
const next = emitted(wrapper);
|
||||
expect(next.candidates).toHaveLength(4);
|
||||
const combined = next.candidates[3];
|
||||
expect(combined.origin).toBe("composite");
|
||||
// The first part is the base, so it carries no relation; every later one
|
||||
// must. A flat relation on the candidate could not express a mixed chain.
|
||||
expect(combined.from?.[0]).toEqual({ value: TESTID });
|
||||
expect(combined.from?.[1]).toEqual({ relation: "and", value: ROLE });
|
||||
// `kind` comes from the first part — "composite" is where it came from, not
|
||||
// how the element is found, so LocatorKind stays a five-value enum.
|
||||
expect(combined.kind).toBe("test_attribute");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a step added by hand", () => {
|
||||
it("is a single required input, with no table above it", () => {
|
||||
const wrapper = render({ candidates: [] });
|
||||
|
||||
expect(wrapper.find(test("synthetics-journey-step-locator-table")).exists()).toBe(false);
|
||||
expect(ownInput(wrapper).exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("How to find this element");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,129 +1,179 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
/**
|
||||
* The Locator block for a version-2 step.
|
||||
* The Locator block for a 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.
|
||||
* **The author owns the order.** It arrives as Playwright's — a unique
|
||||
* candidate first, because `chooseFirstSelector` only reaches for an index when
|
||||
* nothing matched uniquely — and from there the author drags rows, adds
|
||||
* locators of their own, deletes ones they do not want, and combines recorded
|
||||
* ones into something stricter.
|
||||
*
|
||||
* 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.
|
||||
* That replaced pinning (P2.5.0/P2.5.1), which was exclusive: the only way to
|
||||
* say "prefer this one" was to turn fallback off entirely. An ordered list says
|
||||
* the same thing by deleting the other rows, and can also say "prefer mine,
|
||||
* fall back to the recording" — which a pin could not express at all.
|
||||
*
|
||||
* Two things the list must never do:
|
||||
*
|
||||
* - **Rewrite recorded evidence.** An author reorders, appends and deletes.
|
||||
* Editing a recorded value in place would destroy what a later healing pass
|
||||
* compares against, so "Start from this" copies it into the input instead.
|
||||
* - **Hide a positional or generated-id locator.** Both used to be handled by
|
||||
* sorting, invisibly. They are per-row warnings now, because the author is
|
||||
* the only one who can actually resolve either.
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { LocatorCandidate, StepLocator } from "@/types/synthetics";
|
||||
import { isFullyPositional } from "@/utils/synthetics/locatorStability";
|
||||
import { isFrameworkGeneratedId, isPositionalSelector } from "@/utils/synthetics/locatorStability";
|
||||
import { deriveLocatorKind } from "@/utils/synthetics/deriveLocatorKind";
|
||||
import type { CompositePart } from "@/utils/synthetics/composeLocator";
|
||||
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";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import LocatorComposeDialog from "./LocatorComposeDialog.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,
|
||||
/**
|
||||
* A bundle with no candidates — a step added by hand rather than recorded.
|
||||
* The table is `v-if`'d away, so the free-text input is the primary way to name
|
||||
* the element rather than an override of something.
|
||||
*/
|
||||
const isEmpty = computed(() => !candidates.value.length);
|
||||
|
||||
/**
|
||||
* `row-key="value"` rather than a synthesised id.
|
||||
*
|
||||
* Candidates carry no id, and an index-based one changes on every reorder,
|
||||
* which breaks selection mid-drag. Values are unique within a bundle because
|
||||
* `buildLocatorBundle` dedupes by exact string — and that is exactly why adding
|
||||
* a duplicate has to be refused below. Nothing could add a candidate before, so
|
||||
* nothing checked.
|
||||
*/
|
||||
const columns = computed<OTableColumnDef<LocatorCandidate>[]>(() => [
|
||||
{ id: "locator", header: "", size: 200, meta: { autoWidth: true } },
|
||||
{ id: "origin", header: "", size: 96 },
|
||||
{ id: "actions", header: "", size: 10 },
|
||||
]);
|
||||
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const combineOpen = ref(false);
|
||||
|
||||
/** Selected rows in LIST order — the first is the combination's base part. */
|
||||
const selectedParts = computed(() =>
|
||||
candidates.value.filter((c) => selectedIds.value.includes(c.value)),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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));
|
||||
const recordedValues = computed(() =>
|
||||
candidates.value.filter((c) => (c.origin ?? "recorded") === "recorded").map((c) => c.value),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
function originOf(candidate: LocatorCandidate): string {
|
||||
return candidate.origin ?? "recorded";
|
||||
}
|
||||
|
||||
/**
|
||||
* Free text sets `user_override` — it never edits a candidate.
|
||||
* Every emit goes through here, and every one sets `author_ordered`.
|
||||
*
|
||||
* 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.
|
||||
* Reaching this function at all means a human reordered, added, deleted or
|
||||
* combined. Healing must never reorder such a list (H1), so the flag has to be
|
||||
* set by the act rather than by a separate control nobody would think to touch.
|
||||
*/
|
||||
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 = "";
|
||||
function commit(next: LocatorCandidate[]) {
|
||||
emit("update:locator", { ...props.locator, candidates: next, author_ordered: true });
|
||||
}
|
||||
|
||||
function onReorder(next: LocatorCandidate[]) {
|
||||
commit(next);
|
||||
}
|
||||
|
||||
const error = ref("");
|
||||
|
||||
function remove(candidate: LocatorCandidate) {
|
||||
// The step must still name an element, and this block only renders when a
|
||||
// target is mandatory. Deleting the last row would make the journey
|
||||
// unsaveable with no way back inside this block.
|
||||
if (candidates.value.length <= 1) {
|
||||
error.value = t("synthetics.journey.locatorDeleteLast");
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
selectedIds.value = selectedIds.value.filter((v) => v !== candidate.value);
|
||||
commit(candidates.value.filter((c) => c.value !== candidate.value));
|
||||
}
|
||||
|
||||
const draft = ref("");
|
||||
|
||||
/**
|
||||
* The kind the current draft would be stored as — feedback, not a control, so it
|
||||
* can never be set to something the value contradicts.
|
||||
* The kind the current draft would be stored as — feedback, not a control.
|
||||
*
|
||||
* 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.
|
||||
* Always READ from the value, never chosen (D3). `kind` labels a locator; it
|
||||
* does not parse it — every consumer hands `value` to `page.locator()` — so a
|
||||
* picker that only set `kind` would store `role=` semantics on a bare CSS
|
||||
* string.
|
||||
*/
|
||||
const draftKind = computed(() =>
|
||||
overrideDraft.value.trim() ? deriveLocatorKind(overrideDraft.value.trim()) : null,
|
||||
draft.value.trim() ? deriveLocatorKind(draft.value.trim()) : "css",
|
||||
);
|
||||
|
||||
/** Prefill the override with a recorded candidate, as a starting point to edit. */
|
||||
function startFrom(candidate: LocatorCandidate) {
|
||||
overrideDraft.value = candidate.value;
|
||||
function append(candidate: LocatorCandidate): boolean {
|
||||
if (candidates.value.some((c) => c.value === candidate.value)) {
|
||||
error.value = t("synthetics.journey.locatorDuplicate");
|
||||
return false;
|
||||
}
|
||||
error.value = "";
|
||||
commit([...candidates.value, candidate]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isPinnedCandidate(candidate: LocatorCandidate): boolean {
|
||||
return pinned.value?.kind === candidate.kind && pinned.value?.value === candidate.value;
|
||||
function addOwn() {
|
||||
const value = draft.value.trim();
|
||||
if (!value) return;
|
||||
if (append({ kind: deriveLocatorKind(value), value, origin: "authored" })) draft.value = "";
|
||||
}
|
||||
|
||||
/** Copy a row into the input to edit — never edit the recorded value in place. */
|
||||
function startFrom(candidate: LocatorCandidate) {
|
||||
draft.value = candidate.value;
|
||||
}
|
||||
|
||||
function onCombine(built: { value: string; from: CompositePart[] }) {
|
||||
// Appended, then dragged into place. Combining never destroys the evidence it
|
||||
// was built from, and `kind` comes from the first part — `composite` is not a
|
||||
// kind, it is where the locator came from.
|
||||
const added = append({
|
||||
kind: selectedParts.value[0]?.kind ?? deriveLocatorKind(built.value),
|
||||
value: built.value,
|
||||
origin: "composite",
|
||||
from: built.from,
|
||||
});
|
||||
if (added) selectedIds.value = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -132,141 +182,140 @@ function isPinnedCandidate(candidate: LocatorCandidate): boolean {
|
|||
<!-- 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>
|
||||
<span class="text-text-heading text-sm">{{ 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>
|
||||
<!-- Permanent helper text, not a tooltip. Ordering IS the feature; it must
|
||||
not need discovering. -->
|
||||
<p v-if="!isEmpty" class="text-text-secondary m-0 text-xs">
|
||||
{{ t("synthetics.journey.locatorOrderHelp") }}
|
||||
</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"
|
||||
<OTable
|
||||
v-if="!isEmpty"
|
||||
:data="candidates"
|
||||
:columns="columns"
|
||||
row-key="value"
|
||||
:show-header="false"
|
||||
:pagination="'none'"
|
||||
:sorting="'none'"
|
||||
:show-global-filter="false"
|
||||
:dense="true"
|
||||
:bordered="true"
|
||||
:default-columns="false"
|
||||
:fill-height="false"
|
||||
:enable-row-reorder="true"
|
||||
selection="multiple"
|
||||
:selected-ids="selectedIds"
|
||||
data-test="synthetics-journey-step-locator-table"
|
||||
@row-reorder="onReorder"
|
||||
@update:selected-ids="selectedIds = $event"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
<template #cell-locator="{ row }">
|
||||
<div
|
||||
v-for="candidate in fallbacks"
|
||||
:key="`${candidate.kind}:${candidate.value}`"
|
||||
class="flex w-full items-center gap-2 px-2 py-1"
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
data-test="synthetics-journey-step-locator-row"
|
||||
>
|
||||
<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 }}
|
||||
<OBadge variant="default" size="sm">
|
||||
{{ t(`synthetics.journey.locatorKind.${row.kind}`) }}
|
||||
</OBadge>
|
||||
<OTooltip :content="row.value" interactive>
|
||||
<span class="text-text-body min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{{ row.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)"
|
||||
<!-- Per row, not per block. The old whole-block notice fired only when
|
||||
EVERY candidate was positional — that is, only when nothing could
|
||||
be done about it — and stayed silent when something could. -->
|
||||
<OTooltip
|
||||
v-if="isPositionalSelector(row.value)"
|
||||
:content="t('synthetics.journey.locatorPositionalWarningHelp')"
|
||||
>
|
||||
<span
|
||||
class="text-status-warning-text text-2xs flex shrink-0 items-center gap-1"
|
||||
data-test="synthetics-journey-step-locator-row-positional"
|
||||
>
|
||||
{{ 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)"
|
||||
<OIcon name="warning" size="xs" aria-hidden="true" />
|
||||
{{ t("synthetics.journey.locatorPositionalWarning") }}
|
||||
</span>
|
||||
</OTooltip>
|
||||
<OTooltip
|
||||
v-if="isFrameworkGeneratedId(row.value)"
|
||||
:content="t('synthetics.journey.locatorGeneratedIdWarningHelp')"
|
||||
>
|
||||
<span
|
||||
class="text-status-warning-text text-2xs flex shrink-0 items-center gap-1"
|
||||
data-test="synthetics-journey-step-locator-row-generated-id"
|
||||
>
|
||||
{{ t("synthetics.journey.locatorPin") }}
|
||||
</OButton>
|
||||
</div>
|
||||
<OIcon name="warning" size="xs" aria-hidden="true" />
|
||||
{{ t("synthetics.journey.locatorGeneratedIdWarning") }}
|
||||
</span>
|
||||
</OTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</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. -->
|
||||
<template #cell-origin="{ row }">
|
||||
<OBadge variant="default" size="sm">
|
||||
{{ t(`synthetics.journey.locatorOrigin.${originOf(row)}`) }}
|
||||
</OBadge>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
data-test="synthetics-journey-step-locator-delete-btn"
|
||||
icon-left="delete"
|
||||
@click="remove(row)"
|
||||
/>
|
||||
</template>
|
||||
</OTable>
|
||||
|
||||
<!-- Never a primary action: combining is the sharp tool, and it only makes
|
||||
sense for two or more rows. -->
|
||||
<div v-if="selectedParts.length >= 2" class="flex items-center gap-2">
|
||||
<span class="text-text-secondary text-xs">
|
||||
{{ t("synthetics.journey.locatorSelectedCount", { count: selectedParts.length }) }}
|
||||
</span>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
data-test="synthetics-journey-step-locator-combine"
|
||||
@click="combineOpen = true"
|
||||
>
|
||||
{{ t("synthetics.journey.locatorCombine") }}
|
||||
</OButton>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="text-status-error-text m-0 text-xs"
|
||||
data-test="synthetics-journey-step-locator-error"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<!-- The author's own entry, after every recorded one. It APPENDS rather
|
||||
than overriding: the row can then be dragged wherever they want it. -->
|
||||
<div class="flex w-full items-end gap-2">
|
||||
<!-- 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 :content="t('synthetics.journey.locatorDerivedKindHelp')">
|
||||
<OBadge
|
||||
variant="default"
|
||||
size="sm"
|
||||
data-test="synthetics-journey-step-locator-derived-kind"
|
||||
class="mb-2"
|
||||
>
|
||||
{{ t(`synthetics.journey.locatorKind.${draftKind}`) }}
|
||||
</OBadge>
|
||||
</OTooltip>
|
||||
|
||||
<OInput
|
||||
v-model="overrideDraft"
|
||||
v-model="draft"
|
||||
:label="
|
||||
isEmpty
|
||||
? t('synthetics.journey.locatorEmptyLabel')
|
||||
|
|
@ -280,28 +329,26 @@ function isPinnedCandidate(candidate: LocatorCandidate): boolean {
|
|||
:required="isEmpty"
|
||||
class="flex-1"
|
||||
data-test="synthetics-journey-step-locator-override-input"
|
||||
@keyup.enter="applyOverride"
|
||||
@keyup.enter="addOwn"
|
||||
/>
|
||||
<!-- 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"
|
||||
|
||||
<div class="w-23! text-center">
|
||||
<OButton
|
||||
variant="ghost"
|
||||
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>
|
||||
:disabled="!draft.trim()"
|
||||
icon-left="add"
|
||||
data-test="synthetics-journey-step-locator-add"
|
||||
@click="addOwn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LocatorComposeDialog
|
||||
v-model:open="combineOpen"
|
||||
:parts="selectedParts"
|
||||
:recorded="recordedValues"
|
||||
@combine="onCombine"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper } from "@vue/test-utils";
|
||||
|
|
@ -51,7 +64,6 @@ function makeStep(overrides: Partial<BrowserStep> = {}): BrowserStep {
|
|||
name: "Open page",
|
||||
value: "https://example.com",
|
||||
timeout: 30000,
|
||||
code: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -111,8 +123,15 @@ describe("BrowserJourneyStep", () => {
|
|||
expect(wrapper.text()).toContain("Click login");
|
||||
});
|
||||
|
||||
it("should render selector preview text", () => {
|
||||
wrapper = mountStep({ step: makeStep({ action: "click", selector: "#login-btn" }) });
|
||||
// The row shows what the run tries FIRST, which is position 0. A bare
|
||||
// `selector` is the retired version-1 channel and never reaches the wire.
|
||||
it("should render the first locator as the preview", () => {
|
||||
wrapper = mountStep({
|
||||
step: makeStep({
|
||||
action: "click",
|
||||
locator: { candidates: [{ kind: "css", value: "#login-btn" }] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("#login-btn");
|
||||
});
|
||||
|
|
@ -198,9 +217,11 @@ describe("BrowserJourneyStep", () => {
|
|||
});
|
||||
|
||||
// ── 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.
|
||||
// Only the warning is asserted here. The field itself, its placeholder and
|
||||
// the retired-action notice all live inside BrowserJourneyStepEditor — the
|
||||
// input behind its Advanced group — and are covered by that component's own
|
||||
// spec. Reaching for them through this wrapper tested the layout of a
|
||||
// component this one only embeds.
|
||||
describe("timeout guard rails", () => {
|
||||
const timeoutInput = (w: VueWrapper) =>
|
||||
w.find('[data-test="synthetics-journey-step-timeout-input"]');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
|
|
@ -52,15 +67,13 @@ const displayName = computed(() => props.step.name || actionLabel.value);
|
|||
/**
|
||||
* 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.
|
||||
* A step has no bare `selector` — its identity is the locator bundle — so
|
||||
* reading `selector` alone would leave every recorded step's row blank.
|
||||
* Position 0 is what the run tries first, which is what this row is claiming.
|
||||
*/
|
||||
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 || "";
|
||||
});
|
||||
const selectorPreview = computed(
|
||||
() => props.step.locator?.candidates?.[0]?.value || props.step.value || "",
|
||||
);
|
||||
|
||||
// ── Status dot visual mapping (combines with step number during replay) ─────
|
||||
/** Tailwind classes for the step number badge (replay = colored circle). */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { mount } from "@vue/test-utils";
|
||||
|
|
@ -19,8 +32,7 @@ function render(step: Partial<BrowserStep> = {}) {
|
|||
id: "s1",
|
||||
action: "click",
|
||||
name: "Sign in",
|
||||
code: "",
|
||||
locator: { candidates: [], user_override: null },
|
||||
locator: { candidates: [] },
|
||||
...step,
|
||||
};
|
||||
return mount(BrowserJourneyStepEditor, {
|
||||
|
|
@ -92,8 +104,7 @@ describe("BrowserJourneyStepEditor inline field errors", () => {
|
|||
id: "s1",
|
||||
action: "click",
|
||||
name: "Sign in",
|
||||
code: "",
|
||||
locator: { candidates: [], user_override: null },
|
||||
locator: { candidates: [] },
|
||||
...step,
|
||||
};
|
||||
return mount(BrowserJourneyStepEditor, {
|
||||
|
|
@ -266,14 +277,17 @@ describe("BrowserJourneyStepEditor plain language", () => {
|
|||
expect(summary).toContain("30");
|
||||
});
|
||||
|
||||
it("uses the pinned locator in the summary, since that is what runs", () => {
|
||||
it("uses the author's first locator in the summary, since that is what runs", () => {
|
||||
const wrapper = render({
|
||||
locator: {
|
||||
candidates: [{ kind: "css", value: ".ignored" }],
|
||||
user_override: { kind: "css", value: "#pinned" },
|
||||
candidates: [
|
||||
{ kind: "css", value: "#authored", origin: "authored" },
|
||||
{ kind: "css", value: ".fallback" },
|
||||
],
|
||||
author_ordered: true,
|
||||
},
|
||||
});
|
||||
expect(wrapper.find(test("synthetics-journey-step-summary")).text()).toContain("#pinned");
|
||||
expect(wrapper.find(test("synthetics-journey-step-summary")).text()).toContain("#authored");
|
||||
});
|
||||
|
||||
it("reflects an explicit timeout in the summary", () => {
|
||||
|
|
@ -311,7 +325,8 @@ describe("BrowserJourneyStepEditor plain language", () => {
|
|||
});
|
||||
const txt = wrapper.text();
|
||||
expect(txt).toContain("How to find this element");
|
||||
expect(txt).toContain("Always use this one");
|
||||
// Ordering is the feature, so it is stated rather than discovered.
|
||||
expect(txt).toContain("Tried in order, top first");
|
||||
expect(txt).not.toMatch(/\bLocator\b/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrowserStep, SettleResponse, StepAssertion, StepLocator } from "@/types/synthetics";
|
||||
|
|
@ -25,6 +40,7 @@ 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";
|
||||
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
|
||||
|
||||
/**
|
||||
* The expanded editor for one journey step — every author-editable field a step
|
||||
|
|
@ -108,9 +124,7 @@ const showTarget = computed(() => stepNeedsTarget(props.step));
|
|||
* 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 effectiveLocator = computed<StepLocator>(() => props.step.locator ?? { candidates: [] });
|
||||
|
||||
const showValue = computed(() => VALUE_ACTIONS.includes(props.step.action));
|
||||
const valueLabel = computed(
|
||||
|
|
@ -294,11 +308,8 @@ const seconds = (ms: number) => Number((ms / 1000).toFixed(1));
|
|||
// 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 ?? "";
|
||||
});
|
||||
/** What the run will actually target: the author's first choice. */
|
||||
const effectiveTarget = computed(() => props.step.locator?.candidates?.[0]?.value ?? "");
|
||||
|
||||
const summary = computed(() => {
|
||||
const action = ACTION_LABELS[props.step.action] ?? props.step.action;
|
||||
|
|
@ -366,14 +377,14 @@ const advancedCaption = computed(
|
|||
<!-- 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 max-w-200 flex-col gap-3"
|
||||
data-test="synthetics-journey-step-group-does"
|
||||
>
|
||||
<div class="flex w-full gap-2">
|
||||
<OSelect
|
||||
v-model="actionComputed"
|
||||
|
|
@ -405,16 +416,6 @@ const advancedCaption = computed(
|
|||
<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"
|
||||
|
|
@ -440,6 +441,16 @@ const advancedCaption = computed(
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
class="mt-2 w-full max-w-200"
|
||||
@update:locator="updateLocator"
|
||||
/>
|
||||
|
||||
<!-- 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
|
||||
|
|
@ -450,20 +461,20 @@ const advancedCaption = computed(
|
|||
is hidden from them. -->
|
||||
<OCollapsible
|
||||
:label="t('synthetics.journey.groupAdvancedLabel')"
|
||||
:caption="advancedCaption"
|
||||
:default-open="!!advancedChanges"
|
||||
variant="sidebar"
|
||||
class="mt-2 w-full max-w-200 border"
|
||||
data-test="synthetics-journey-step-group-advanced"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-3 pt-2">
|
||||
<div class="flex w-full flex-col gap-3 px-2 py-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>
|
||||
<span class="text-text-heading text-sm">{{ t("synthetics.journey.settleLabel") }}</span>
|
||||
|
||||
<p v-if="settleNavigationLine" class="text-text-secondary m-0 font-mono text-xs">
|
||||
<OSeparator />
|
||||
<p v-if="settleNavigationLine" class="text-text-secondary m-0 text-xs">
|
||||
{{ settleNavigationLine }}
|
||||
</p>
|
||||
|
||||
|
|
@ -483,10 +494,6 @@ const advancedCaption = computed(
|
|||
{{ settleResponseLabel(response) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="settleObservedLine" class="text-text-secondary m-0 font-mono text-xs">
|
||||
{{ settleObservedLine }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<OInput
|
||||
|
|
@ -494,7 +501,7 @@ const advancedCaption = computed(
|
|||
:label="t('synthetics.journey.settleBudgetLabel')"
|
||||
:placeholder="String(DEFAULT_SETTLE_BUDGET_MS)"
|
||||
type="number"
|
||||
class="w-full"
|
||||
class="mt-2 w-75!"
|
||||
data-test="synthetics-journey-step-settle-budget-input"
|
||||
/>
|
||||
<p
|
||||
|
|
@ -512,19 +519,17 @@ const advancedCaption = computed(
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<OSeparator />
|
||||
|
||||
<OInput
|
||||
v-model="timeoutComputed"
|
||||
:label="t('synthetics.journey.timeoutLabel')"
|
||||
:placeholder="String(timeoutDefault)"
|
||||
:helpText="timeoutHelp"
|
||||
type="number"
|
||||
class="w-full"
|
||||
class="w-75!"
|
||||
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"
|
||||
|
|
@ -541,6 +546,8 @@ const advancedCaption = computed(
|
|||
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). -->
|
||||
<OSeparator />
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<OCheckbox
|
||||
v-model="optionalComputed"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { mount } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
// Copyright 2026 OpenObserve Inc.
|
||||
<!-- 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">
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises, config } from "@vue/test-utils";
|
||||
|
|
@ -70,10 +83,8 @@ function makeStep(overrides: Partial<BrowserStep> = {}): BrowserStep {
|
|||
action: "click",
|
||||
name: "Click Login Button",
|
||||
selector: "#login-btn",
|
||||
selectorType: "CSS",
|
||||
value: "",
|
||||
timeout: 30000,
|
||||
code: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
<!-- Copyright 2026 OpenObserve Inc. -->
|
||||
<!-- 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/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
JourneySteps — Thin OTable wrapper for synthetic monitoring step lists.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
// 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 { mount, VueWrapper } from "@vue/test-utils";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import type { LocatorCandidate } from "@/types/synthetics";
|
||||
import LocatorComposeDialog from "./LocatorComposeDialog.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> },
|
||||
});
|
||||
|
||||
// ODialog teleports its content to document.body, which would put every
|
||||
// assertion below outside the wrapper's tree. Stubbed to render inline so the
|
||||
// builder is what is under test rather than the teleport.
|
||||
const ODialogStub = {
|
||||
name: "ODialog",
|
||||
props: ["open", "title", "primaryButtonLabel", "secondaryButtonLabel", "primaryButtonDisabled"],
|
||||
emits: ["update:open", "click:primary", "click:secondary"],
|
||||
template: `
|
||||
<div v-if="open" class="dialog-stub">
|
||||
<slot />
|
||||
<button data-test="stub-primary" :disabled="primaryButtonDisabled" @click="$emit('click:primary')" />
|
||||
</div>`,
|
||||
};
|
||||
|
||||
const TESTID = '[data-test="org-row"]';
|
||||
const ROLE = 'internal:role=row[name="acme_prod"i]';
|
||||
const TEXT = 'internal:text="acme_prod"';
|
||||
|
||||
const c = (value: string, kind: LocatorCandidate["kind"] = "css"): LocatorCandidate => ({
|
||||
kind,
|
||||
value,
|
||||
});
|
||||
|
||||
function render(parts: LocatorCandidate[], recorded: string[] = parts.map((p) => p.value)) {
|
||||
return mount(LocatorComposeDialog, {
|
||||
props: { open: true, parts, recorded },
|
||||
global: { plugins: [i18n], stubs: { ODialog: ODialogStub } },
|
||||
});
|
||||
}
|
||||
|
||||
const test = (name: string) => `[data-test="${name}"]`;
|
||||
const result = (w: VueWrapper) => w.find(test("synthetics-journey-step-locator-combine-result"));
|
||||
const picker = (w: VueWrapper) => w.find(test("synthetics-journey-step-locator-combine-relation"));
|
||||
|
||||
describe("LocatorComposeDialog", () => {
|
||||
it("lists the parts and says the element must match all of them", () => {
|
||||
const wrapper = render([c(TESTID, "test_attribute"), c(ROLE, "role")]);
|
||||
|
||||
expect(wrapper.text()).toContain("must match ALL of these");
|
||||
expect(wrapper.findAll(test("synthetics-journey-step-locator-combine-part"))).toHaveLength(2);
|
||||
// "AND", "intersect" and "composite" never appear in body copy. `Combined`
|
||||
// shows up only as the origin badge, back in the list.
|
||||
expect(wrapper.text()).not.toMatch(/\bintersect/i);
|
||||
expect(wrapper.text()).not.toMatch(/\bcomposite\b/i);
|
||||
});
|
||||
|
||||
// Derived from the positional invariant, not from a live page. Playwright
|
||||
// appends an index only when nothing matched uniquely, so its absence proves
|
||||
// the selector resolved to exactly one element at record time. There is no
|
||||
// page to count against here, and a made-up count would be worse than none.
|
||||
it("reports what each part matched when it was recorded", () => {
|
||||
const wrapper = render([c(`${TESTID} >> nth=1`, "test_attribute"), c(ROLE, "role")]);
|
||||
const parts = wrapper.findAll(test("synthetics-journey-step-locator-combine-part"));
|
||||
|
||||
expect(parts[0].text()).toContain("matched several when recorded");
|
||||
expect(parts[1].text()).toContain("matched 1 when recorded");
|
||||
});
|
||||
|
||||
it("names the failure mode and the mitigation, before the fact", () => {
|
||||
const warning = render([c(TESTID), c(ROLE)]).find(
|
||||
test("synthetics-journey-step-locator-combine-warning"),
|
||||
);
|
||||
|
||||
expect(warning.text()).toContain("stricter, not more resilient");
|
||||
expect(warning.text()).toContain("still tried after it");
|
||||
});
|
||||
});
|
||||
|
||||
// `and` is provably safe when every part came from the recording: Playwright
|
||||
// verified each against the same target element, so intersecting them gives
|
||||
// that element. Asking there adds a click and teaches nothing.
|
||||
describe("the relation picker", () => {
|
||||
it("stays hidden, and applies `and`, for a recorded-only selection", () => {
|
||||
const wrapper = render([c(TESTID), c(ROLE)]);
|
||||
|
||||
expect(picker(wrapper).exists()).toBe(false);
|
||||
expect(result(wrapper).text()).toBe(`${TESTID} >> internal:and=${JSON.stringify(ROLE)}`);
|
||||
});
|
||||
|
||||
it("appears, with nothing pre-selected, once an authored part joins in", () => {
|
||||
const wrapper = render([c(TESTID), c("#mine")], [TESTID]);
|
||||
|
||||
expect(picker(wrapper).exists()).toBe(true);
|
||||
expect(picker(wrapper).findAll("[data-state='checked']")).toHaveLength(0);
|
||||
// A wrong default here produces an empty match the editor cannot detect, so
|
||||
// there is nothing to build until they choose.
|
||||
expect(result(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("builds a containment locator when the author says the first wraps the second", async () => {
|
||||
const wrapper = render([c(TESTID), c(TEXT)], [TESTID]);
|
||||
await wrapper.findComponent({ name: "ORadioGroup" }).vm.$emit("update:modelValue", "has");
|
||||
|
||||
expect(result(wrapper).text()).toBe(`${TESTID} >> internal:has=${JSON.stringify(TEXT)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// The defect the Task 0 spike caught. `and` intersects result sets and runs the
|
||||
// inner selector against the document root, so a filter engine on the right
|
||||
// filters the document, matches nothing, and the whole thing resolves to
|
||||
// nothing — silently, with no error anywhere.
|
||||
describe("a part that can only narrow something else down", () => {
|
||||
it("is refused under `and`, and the author is steered to wraps", async () => {
|
||||
const wrapper = render([c(TESTID), c('internal:has-text="acme_prod"')], [TESTID]);
|
||||
await wrapper.findComponent({ name: "ORadioGroup" }).vm.$emit("update:modelValue", "and");
|
||||
|
||||
expect(
|
||||
wrapper.find(test("synthetics-journey-step-locator-combine-filter-error")).exists(),
|
||||
).toBe(true);
|
||||
expect(wrapper.find(test("stub-primary")).attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("is fine under wraps", async () => {
|
||||
const wrapper = render([c(TESTID), c('internal:has-text="acme_prod"')], [TESTID]);
|
||||
await wrapper.findComponent({ name: "ORadioGroup" }).vm.$emit("update:modelValue", "has");
|
||||
|
||||
expect(
|
||||
wrapper.find(test("synthetics-journey-step-locator-combine-filter-error")).exists(),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The one control that picks between the two shapes in the design: precision,
|
||||
// or an assertion that fails loudly the day the list is reordered.
|
||||
describe("the position toggle", () => {
|
||||
const toggle = (w: VueWrapper) =>
|
||||
w.find(test("synthetics-journey-step-locator-combine-position"));
|
||||
|
||||
it("is absent when the base part carries no index", () => {
|
||||
expect(toggle(render([c(TESTID), c(ROLE)])).exists()).toBe(false);
|
||||
});
|
||||
|
||||
// `nth=1` is the second match. Authors count from one, and the caption says
|
||||
// what happens rather than what the token is.
|
||||
it("offers the 1-based position an author would count", () => {
|
||||
const wrapper = render([c(`${TESTID} >> nth=1`), c(ROLE)]);
|
||||
expect(toggle(wrapper).exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("position 2");
|
||||
expect(wrapper.text()).toContain("list that has been reordered");
|
||||
});
|
||||
|
||||
it("strips the index by default, and keeps it before the join when asked", async () => {
|
||||
const wrapper = render([c(`${TESTID} >> nth=1`), c(ROLE)]);
|
||||
const join = `internal:and=${JSON.stringify(ROLE)}`;
|
||||
|
||||
// Off by default: an index is what combining exists to remove.
|
||||
expect(result(wrapper).text()).toBe(`${TESTID} >> ${join}`);
|
||||
|
||||
await wrapper.findComponent({ name: "OCheckbox" }).vm.$emit("update:modelValue", true);
|
||||
|
||||
const guarded = result(wrapper).text();
|
||||
expect(guarded).toBe(`${TESTID} >> nth=1 >> ${join}`);
|
||||
// Order inside the string is load-bearing: index after the join would apply
|
||||
// to the intersection, which holds one element, and match nothing.
|
||||
expect(guarded.indexOf("nth=")).toBeLessThan(guarded.indexOf("internal:and="));
|
||||
});
|
||||
});
|
||||
|
||||
describe("applying", () => {
|
||||
it("emits the built value and the parts it was built from", async () => {
|
||||
const wrapper = render([c(TESTID, "test_attribute"), c(ROLE, "role")]);
|
||||
await wrapper.find(test("stub-primary")).trigger("click");
|
||||
|
||||
const [payload] = wrapper.emitted("combine")![0] as [
|
||||
{ value: string; from: { value: string; relation?: string }[] },
|
||||
];
|
||||
expect(payload.value).toContain("internal:and=");
|
||||
// The base carries no relation; every later part does. That is what lets a
|
||||
// mixed chain — `has` then `descendant` — be expressed at all.
|
||||
expect(payload.from).toEqual([{ value: TESTID }, { relation: "and", value: ROLE }]);
|
||||
expect(wrapper.emitted("update:open")!.at(-1)).toEqual([false]);
|
||||
});
|
||||
|
||||
it("cannot be applied while the relation is unanswered", () => {
|
||||
const wrapper = render([c(TESTID), c("#mine")], [TESTID]);
|
||||
expect(wrapper.find(test("stub-primary")).attributes("disabled")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
<!-- 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 Combine builder.
|
||||
*
|
||||
* Combining is the sharp tool in the Locator block, and its intuition runs
|
||||
* backwards: **it makes a step stricter, not more resilient.** `A ∧ B` breaks
|
||||
* if either A or B breaks, so as a resilience measure it is worse than either
|
||||
* locator alone. What it buys is precision — finding an element that no single
|
||||
* recorded locator can identify — and, if the index is kept, drift detection.
|
||||
*
|
||||
* Resilience stays with the ordered list: that is the OR. This is the AND.
|
||||
*
|
||||
* the ordered list → "keep the step running" → availability
|
||||
* a combined locator → "act on the right element, or fail" → correctness
|
||||
*
|
||||
* No `OForm`, no Zod. There is nothing to validate: the parts are pre-selected,
|
||||
* the relation is a radio and the position is a checkbox. `OForm` + Zod is the
|
||||
* rule for validated forms with free-text input; this is a builder, and the
|
||||
* free-text path lives in the Locator block itself.
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { CompositeRelation, LocatorCandidate } from "@/types/synthetics";
|
||||
import {
|
||||
canDefaultToAnd,
|
||||
composeLocator,
|
||||
isBareFilterEngine,
|
||||
type CompositePart,
|
||||
} from "@/utils/synthetics/composeLocator";
|
||||
import { isPositionalSelector } from "@/utils/synthetics/locatorStability";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import OBadge from "@/lib/core/Badge/OBadge.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
|
||||
import ORadioGroup from "@/lib/forms/Radio/ORadioGroup.vue";
|
||||
import ORadio from "@/lib/forms/Radio/ORadio.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
/** The selected rows, in list order. The first is the base. */
|
||||
parts: LocatorCandidate[];
|
||||
/** Values of every `recorded` candidate in the bundle — the safety proof. */
|
||||
recorded: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [value: boolean];
|
||||
combine: [value: { value: string; from: CompositePart[] }];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* `and` is provably safe when every selected row came from the recording.
|
||||
*
|
||||
* Playwright verifies each candidate against the target before storing it —
|
||||
* `chooseFirstSelector` returns only on `result[0] === targetElement &&
|
||||
* result.length === 1` — so recorded candidates all resolve to the same element
|
||||
* and intersecting them gives that element. Asking the author in that case adds
|
||||
* a click and teaches nothing.
|
||||
*
|
||||
* The guarantee ends the moment a locator they typed joins in, and the editor
|
||||
* has no live DOM with which to notice a wrong choice: picking `and` when the
|
||||
* parts sit on different elements yields nothing at all, silently. So the
|
||||
* picker appears, with nothing pre-selected.
|
||||
*/
|
||||
const provable = computed(() =>
|
||||
canDefaultToAnd(
|
||||
props.parts.map((p) => ({ value: p.value })),
|
||||
props.recorded,
|
||||
),
|
||||
);
|
||||
|
||||
const relation = ref<CompositeRelation | null>(null);
|
||||
const requirePosition = ref(false);
|
||||
|
||||
watch(
|
||||
() => [props.open, props.parts] as const,
|
||||
() => {
|
||||
relation.value = null;
|
||||
requirePosition.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
/** The relation actually applied: `and` when it is provable, else the choice. */
|
||||
const effectiveRelation = computed<CompositeRelation | null>(() =>
|
||||
provable.value ? "and" : relation.value,
|
||||
);
|
||||
|
||||
/**
|
||||
* The index the base part carries, if any — the only one that can be kept.
|
||||
*
|
||||
* A later part's index rides inside its quoted body and is not ours to move.
|
||||
*/
|
||||
const basePosition = computed(() => {
|
||||
const match = props.parts[0]?.value.match(/>>\s*nth=(\d+)\s*$/);
|
||||
return match ? Number(match[1]) : null;
|
||||
});
|
||||
|
||||
const composedParts = computed<CompositePart[]>(() => {
|
||||
const rel = effectiveRelation.value;
|
||||
if (!rel) return [];
|
||||
const [base, ...rest] = props.parts;
|
||||
if (!base) return [];
|
||||
return [{ value: base.value }, ...rest.map((p) => ({ relation: rel, value: p.value }))];
|
||||
});
|
||||
|
||||
const result = computed(() =>
|
||||
composedParts.value.length
|
||||
? composeLocator({
|
||||
parts: composedParts.value,
|
||||
requirePosition: requirePosition.value,
|
||||
})
|
||||
: "",
|
||||
);
|
||||
|
||||
/**
|
||||
* A part that only NARROWS something else down cannot be intersected with.
|
||||
*
|
||||
* `and` intersects result sets and runs the inner selector against the document
|
||||
* root, so a filter engine — has-text, has, has-not — filters the document,
|
||||
* matches nothing, and the whole combination resolves to nothing. Silently: the
|
||||
* step simply never finds its element. `has` expresses the same intent.
|
||||
*
|
||||
* A recorded candidate is never a bare filter engine, so this only fires on a
|
||||
* locator the author typed.
|
||||
*/
|
||||
const filterEngineUnderAnd = computed(
|
||||
() =>
|
||||
effectiveRelation.value === "and" &&
|
||||
props.parts.slice(1).some((p) => isBareFilterEngine(p.value)),
|
||||
);
|
||||
|
||||
const canApply = computed(() => !!result.value && !filterEngineUnderAnd.value);
|
||||
|
||||
/**
|
||||
* "matched 1 when recorded" — the only honest count available.
|
||||
*
|
||||
* Derived from whether the part carries a positional token: Playwright appends
|
||||
* one only when nothing identified the element uniquely, so its absence proves
|
||||
* the selector resolved to exactly one element at record time. There is no live
|
||||
* page here, so a real count cannot be shown and is not invented.
|
||||
*/
|
||||
function matchedLabel(candidate: LocatorCandidate): string {
|
||||
return isPositionalSelector(candidate.value)
|
||||
? t("synthetics.journey.locatorCombineMatchedMany")
|
||||
: t("synthetics.journey.locatorCombineMatchedOne");
|
||||
}
|
||||
|
||||
function apply() {
|
||||
if (!canApply.value) return;
|
||||
emit("combine", { value: result.value, from: composedParts.value });
|
||||
emit("update:open", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ODialog
|
||||
:open="open"
|
||||
:title="t('synthetics.journey.locatorCombineTitle')"
|
||||
:primary-button-label="t('synthetics.journey.locatorCombineApply')"
|
||||
:secondary-button-label="t('common.cancel')"
|
||||
:primary-button-disabled="!canApply"
|
||||
data-test="synthetics-journey-step-locator-combine-dialog"
|
||||
@update:open="emit('update:open', $event)"
|
||||
@click:primary="apply"
|
||||
@click:secondary="emit('update:open', false)"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- Plain language throughout. "AND", "intersect" and "composite" never
|
||||
appear in body copy; `Combined` shows up only as the origin badge. -->
|
||||
<section class="flex flex-col gap-2">
|
||||
<p class="text-text-secondary m-0 text-xs">
|
||||
{{ t("synthetics.journey.locatorCombineLead") }}
|
||||
</p>
|
||||
<div
|
||||
v-for="part in parts"
|
||||
:key="part.value"
|
||||
class="flex items-center gap-2"
|
||||
data-test="synthetics-journey-step-locator-combine-part"
|
||||
>
|
||||
<OBadge variant="default" size="sm">
|
||||
{{ t(`synthetics.journey.locatorKind.${part.kind}`) }}
|
||||
</OBadge>
|
||||
<span class="text-text-body min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{{ part.value }}
|
||||
</span>
|
||||
<span class="text-text-muted text-2xs shrink-0">{{ matchedLabel(part) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Shown only where the proof does not hold, with nothing pre-selected:
|
||||
a wrong default here produces an empty match the editor cannot
|
||||
detect, so the choice has to be deliberate. -->
|
||||
<section
|
||||
v-if="!provable"
|
||||
class="flex flex-col gap-2"
|
||||
data-test="synthetics-journey-step-locator-combine-relation"
|
||||
>
|
||||
<p class="text-text-body m-0 text-sm">
|
||||
{{ t("synthetics.journey.locatorCombineRelationQuestion") }}
|
||||
</p>
|
||||
<ORadioGroup
|
||||
:model-value="relation ?? undefined"
|
||||
@update:model-value="relation = $event as CompositeRelation"
|
||||
>
|
||||
<ORadio
|
||||
value="and"
|
||||
:label="t('synthetics.journey.locatorCombineRelationAnd')"
|
||||
data-test="synthetics-journey-step-locator-combine-relation-and"
|
||||
/>
|
||||
<ORadio
|
||||
value="has"
|
||||
:label="t('synthetics.journey.locatorCombineRelationHas')"
|
||||
data-test="synthetics-journey-step-locator-combine-relation-has"
|
||||
/>
|
||||
</ORadioGroup>
|
||||
<p class="text-text-muted m-0 pl-6 text-xs">
|
||||
{{ t("synthetics.journey.locatorCombineRelationHasHelp") }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- The one control that picks between the two shapes: precision, or an
|
||||
assertion that fails loudly when the list is reordered. Off by
|
||||
default, captioned with the consequence rather than the mechanism. -->
|
||||
<section v-if="basePosition !== null" class="flex flex-col gap-1">
|
||||
<OCheckbox
|
||||
v-model="requirePosition"
|
||||
:label="t('synthetics.journey.locatorCombinePosition', { position: basePosition + 1 })"
|
||||
data-test="synthetics-journey-step-locator-combine-position"
|
||||
/>
|
||||
<p class="text-text-muted m-0 pl-6 text-xs">
|
||||
{{ t("synthetics.journey.locatorCombinePositionHelp") }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Names the failure mode and the mitigation, in the dialog rather than
|
||||
after the fact. Authors read "add another locator" as more robust. -->
|
||||
<p
|
||||
class="text-status-warning-text m-0 flex items-start gap-1 text-xs"
|
||||
data-test="synthetics-journey-step-locator-combine-warning"
|
||||
>
|
||||
<OIcon name="warning" size="xs" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t("synthetics.journey.locatorCombineWarning") }}</span>
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="filterEngineUnderAnd"
|
||||
class="text-status-error-text m-0 flex items-start gap-1 text-xs"
|
||||
data-test="synthetics-journey-step-locator-combine-filter-error"
|
||||
>
|
||||
<OIcon name="error-outline" size="xs" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t("synthetics.journey.locatorCombineFilterEngine") }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Shown, not editable: it is reassurance and a copy target. Editing it
|
||||
would mean hand-writing JSON-escaped selector syntax. -->
|
||||
<section v-if="result" class="flex flex-col gap-1">
|
||||
<span class="text-text-secondary text-xs">
|
||||
{{ t("synthetics.journey.locatorCombineResult") }}
|
||||
</span>
|
||||
<code
|
||||
class="bg-surface-subtle text-text-body rounded-default overflow-x-auto p-2 font-mono text-xs"
|
||||
data-test="synthetics-journey-step-locator-combine-result"
|
||||
>
|
||||
{{ result }}
|
||||
</code>
|
||||
</section>
|
||||
</div>
|
||||
</ODialog>
|
||||
</template>
|
||||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { mount } from "@vue/test-utils";
|
||||
|
|
@ -16,7 +29,7 @@ function step(kind: string | null): BrowserStep {
|
|||
id: "s1",
|
||||
action: "click",
|
||||
name: "Click",
|
||||
locator: kind ? { candidates: [{ kind, value: "x" }], user_override: null } : undefined,
|
||||
locator: kind ? { candidates: [{ kind, value: "x" }] } : undefined,
|
||||
} as unknown as BrowserStep;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
/**
|
||||
* "This recording found no test attributes" — the misconfiguration that is
|
||||
* otherwise silent.
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { mount } from "@vue/test-utils";
|
||||
|
|
@ -15,7 +28,7 @@ const i18n = createI18n({
|
|||
});
|
||||
|
||||
function step(overrides: Partial<BrowserStep> = {}): BrowserStep {
|
||||
return { id: "s1", action: "click", name: "Sign In", code: "", ...overrides };
|
||||
return { id: "s1", action: "click", name: "Sign In", ...overrides };
|
||||
}
|
||||
|
||||
function render(steps: BrowserStep[]) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
/**
|
||||
* The nudge for a journey that verifies nothing.
|
||||
*
|
||||
|
|
@ -44,7 +59,6 @@ function addAssertion() {
|
|||
action: "assert",
|
||||
name: t("synthetics.journey.assertionSuggestedName"),
|
||||
assertion: { kind: "element_visible" },
|
||||
code: "",
|
||||
});
|
||||
dismissed.value = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
// 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, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
|
||||
import i18n from "@/locales";
|
||||
import EvidenceEvents from "./EvidenceEvents.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import type { EvidenceEvent } from "@/composables/synthetics/syntheticResultsSchema";
|
||||
|
||||
const ev = (over: Partial<EvidenceEvent>): EvidenceEvent => ({
|
||||
ts: 0,
|
||||
stepId: "s1",
|
||||
kind: "response",
|
||||
level: null,
|
||||
text: null,
|
||||
message: null,
|
||||
stack: null,
|
||||
method: null,
|
||||
url: null,
|
||||
status: null,
|
||||
resourceType: null,
|
||||
initiatedTs: null,
|
||||
durationMs: null,
|
||||
firstParty: true,
|
||||
stepName: "Click Sign In",
|
||||
...over,
|
||||
});
|
||||
|
||||
const EVENTS = [
|
||||
ev({ kind: "pageerror", message: "Uncaught TypeError: c.user is undefined" }),
|
||||
ev({ status: 503, method: "POST", url: "https://app.dev/auth/login", durationMs: 1200 }),
|
||||
];
|
||||
|
||||
const mountEvents = (props: Record<string, unknown> = {}) =>
|
||||
mount(EvidenceEvents, {
|
||||
props: { events: EVENTS, mode: "panel", ...props },
|
||||
global: { plugins: [i18n] },
|
||||
});
|
||||
|
||||
describe("EvidenceEvents", () => {
|
||||
it("renders one row per event", () => {
|
||||
const w = mountEvents();
|
||||
expect(w.findAll('[data-test="synthetics-evidence-events-row"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows the step column in panel mode", () => {
|
||||
const w = mountEvents({ mode: "panel" });
|
||||
expect(w.find('[data-test="synthetics-evidence-events-step"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("drops the step column inline, where the step is already the context", () => {
|
||||
const w = mountEvents({ mode: "inline" });
|
||||
expect(w.find('[data-test="synthetics-evidence-events-step"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a response status and hides it for a page error", () => {
|
||||
const w = mountEvents();
|
||||
const cells = w.findAll('[data-test="synthetics-evidence-events-status"]');
|
||||
expect(cells[0].text()).toBe("");
|
||||
expect(cells[1].text()).toContain("503");
|
||||
});
|
||||
|
||||
it("truncates a url from the left, because the host repeats and the path differs", () => {
|
||||
const w = mountEvents();
|
||||
expect(w.text()).toContain("/auth/login");
|
||||
});
|
||||
|
||||
it("dims a third-party row rather than dropping it", () => {
|
||||
// A failing third-party script is a legitimate cause of a broken page.
|
||||
const w = mountEvents({ events: [ev({ status: 200, firstParty: false })] });
|
||||
expect(w.find('[data-test="synthetics-evidence-events-row"]').classes().join(" ")).toContain(
|
||||
"opacity-60",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a filtered empty state that can clear the filter", async () => {
|
||||
const w = mountEvents({ events: [], mode: "panel", filtered: true });
|
||||
const empty = w.findComponent(OEmptyState);
|
||||
expect(empty.props("filtered")).toBe(true);
|
||||
empty.vm.$emit("action", "clear-filters");
|
||||
await w.vm.$nextTick();
|
||||
expect(w.emitted("clear-filters")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
<!-- 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/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
EvidenceEvents — Thin OTable wrapper for evidence bundle rows.
|
||||
|
||||
Two modes:
|
||||
inline — inside a step expansion: no step column (the step IS the context),
|
||||
no pagination, shrink to content
|
||||
panel — the run-level Evidence tab: step column, virtual scroll
|
||||
|
||||
One row definition for both surfaces, so a change to a row is a change to
|
||||
both. This component owns the OTable configuration and renders cell content
|
||||
through OTable cell slots; ranking, capping, fetching and filtering stay with
|
||||
the callers.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import type { EvidenceEvent } from "@/composables/synthetics/syntheticResultsSchema";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
events: EvidenceEvent[];
|
||||
mode: "inline" | "panel";
|
||||
/** Panel only — the list HAS events but the current filter matched none. */
|
||||
filtered?: boolean;
|
||||
}>(),
|
||||
{ filtered: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: "clear-filters"): void }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/** OTable needs a stable row key; a bundle line has no id of its own. */
|
||||
interface EvidenceRow extends EvidenceEvent {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const rows = computed<EvidenceRow[]>(() =>
|
||||
props.events.map((e, i) => ({ ...e, id: `${e.kind}-${e.initiatedTs ?? e.ts}-${i}` })),
|
||||
);
|
||||
|
||||
const isPanel = computed(() => props.mode === "panel");
|
||||
|
||||
const columns = computed<OTableColumnDef<EvidenceRow>[]>(() => [
|
||||
{ id: "status", header: "", size: 48 },
|
||||
{ id: "method", header: "", size: 64 },
|
||||
{ id: "message", header: "", size: 200, meta: { autoWidth: true } },
|
||||
...(isPanel.value ? [{ id: "step", header: "", size: 160 }] : []),
|
||||
{ id: "duration", header: "", size: 64 },
|
||||
]);
|
||||
|
||||
/** 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 statusText(e: EvidenceEvent): string {
|
||||
if (e.kind === "response") return String(e.status ?? "—");
|
||||
if (e.kind === "requestfailed") return "—";
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Both timestamps, so bucketing ambiguity is visible rather than silently wrong. */
|
||||
function rowTitle(e: EvidenceEvent): string {
|
||||
const label = e.url ?? e.text ?? e.message ?? e.kind;
|
||||
return e.initiatedTs != null && e.initiatedTs !== e.ts
|
||||
? `${label}\ninitiated ${e.initiatedTs} · observed ${e.ts}`
|
||||
: label;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OTable
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
:show-header="false"
|
||||
:pagination="'none'"
|
||||
:sorting="'none'"
|
||||
:show-global-filter="false"
|
||||
:dense="true"
|
||||
:bordered="true"
|
||||
:default-columns="false"
|
||||
:fill-height="false"
|
||||
:frame="false"
|
||||
:virtual-scroll="isPanel"
|
||||
data-test="synthetics-evidence-events"
|
||||
>
|
||||
<template #cell-status="{ row }">
|
||||
<span
|
||||
class="font-mono text-xs"
|
||||
:class="statusClass(row)"
|
||||
data-test="synthetics-evidence-events-status"
|
||||
>
|
||||
{{ statusText(row) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-method="{ row }">
|
||||
<span class="text-text-secondary font-mono text-xs">
|
||||
{{ row.method ?? row.level ?? "" }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-message="{ row }">
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
:class="row.firstParty ? '' : 'opacity-60'"
|
||||
:title="rowTitle(row)"
|
||||
data-test="synthetics-evidence-events-row"
|
||||
>
|
||||
<OIcon
|
||||
v-if="row.kind === 'pageerror' || row.kind === 'crash'"
|
||||
name="error"
|
||||
size="xs"
|
||||
class="text-status-error-text shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-text-body min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{{ shortUrl(row.url) || row.text || row.message || row.kind }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="isPanel" #cell-step="{ row }">
|
||||
<span
|
||||
class="text-text-secondary truncate text-xs"
|
||||
:title="row.stepName ?? ''"
|
||||
data-test="synthetics-evidence-events-step"
|
||||
>
|
||||
{{ row.stepName ?? t("synthetics.evidence.unattributed") }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-duration="{ row }">
|
||||
<span class="text-text-secondary text-right font-mono text-xs">
|
||||
{{ row.durationMs != null ? `${row.durationMs}ms` : "" }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<OEmptyState
|
||||
size="inline"
|
||||
icon="search"
|
||||
:filtered="filtered"
|
||||
:title="t('synthetics.evidence.noEvents')"
|
||||
data-test="synthetics-evidence-events-empty"
|
||||
@action="(id?: string) => id === 'clear-filters' && emit('clear-filters')"
|
||||
/>
|
||||
</template>
|
||||
</OTable>
|
||||
</template>
|
||||
|
|
@ -1,7 +1,20 @@
|
|||
// 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, it, expect, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
|
||||
import i18n from "@/locales";
|
||||
import EvidencePanel from "./EvidencePanel.vue";
|
||||
|
|
@ -151,29 +164,39 @@ describe("evidence grouping", () => {
|
|||
});
|
||||
|
||||
describe("EvidencePanel", () => {
|
||||
// The panel no longer fetches; the composable does. It receives events.
|
||||
//
|
||||
// NDJSON's four lines all sit on s19, so a fifth on another step is added
|
||||
// here — without it, "filtered to s19" and "the whole run" are the same set
|
||||
// and the narrowing assertion below would pass on a broken filter.
|
||||
const OTHER_STEP =
|
||||
'{"ts":1785356287000,"kind":"response","method":"GET","url":"https://o2.example.dev/api/late","status":404,"initiated_ts":1785356286900,"first_party":true,"step_id":"fa1"}';
|
||||
|
||||
const named = () =>
|
||||
parseEvidenceNdjson(`${NDJSON}\n${OTHER_STEP}`).map((e) => ({
|
||||
...e,
|
||||
stepName: e.stepId ? STEP_DEFS.get(e.stepId)?.name || e.stepId : null,
|
||||
}));
|
||||
|
||||
const mountPanel = (props: Record<string, unknown> = {}) =>
|
||||
mount(EvidencePanel, {
|
||||
props: {
|
||||
evidenceKey: "synthetics/org/mon/2026/07/29/RUN/EXEC/attempt-1-evidence.ndjson",
|
||||
evidenceKey: "synthetics/org/mon/RUN/EXEC/attempt-1-evidence.ndjson",
|
||||
resolveUrl: (k: string) => `/artifact?key=${k}`,
|
||||
stepDefs: STEP_DEFS,
|
||||
events: named(),
|
||||
status: "ready",
|
||||
error: null,
|
||||
truncated: false,
|
||||
stepFilter: null,
|
||||
stepFilterName: "",
|
||||
...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 () => {
|
||||
it("renders a section per kind", () => {
|
||||
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);
|
||||
|
|
@ -181,75 +204,130 @@ describe("EvidencePanel", () => {
|
|||
expect(w.find('[data-test="synthetics-evidence-group-pageErrors"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows which step each row belongs to", async () => {
|
||||
it("shows which step each row belongs to", () => {
|
||||
const w = mountPanel();
|
||||
await flushPromises();
|
||||
expect(w.find('[data-test="synthetics-evidence-row-step"]').text()).toContain(
|
||||
expect(w.find('[data-test="synthetics-evidence-events-step"]').text()).toContain(
|
||||
"Navigate to /web/login",
|
||||
);
|
||||
});
|
||||
|
||||
it("refetches when the attempt changes, so bundles never cross labels", async () => {
|
||||
it("keeps zero-count chips visible rather than hiding them", () => {
|
||||
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("narrows both the rows and the chip counts to the filtered step", () => {
|
||||
// A chip that counts the whole run while the list shows one step is a lie.
|
||||
const all = mountPanel();
|
||||
const scoped = mountPanel({ stepFilter: "s19", stepFilterName: "Navigate to /web/login" });
|
||||
expect(scoped.find('[data-test="synthetics-evidence-chip-all"]').text()).not.toBe(
|
||||
all.find('[data-test="synthetics-evidence-chip-all"]').text(),
|
||||
);
|
||||
expect(scoped.find('[data-test="synthetics-evidence-step-filter"]').text()).toContain(
|
||||
"Navigate to /web/login",
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes capture-off from not-kept from absent", async () => {
|
||||
it("clears the step filter on request", async () => {
|
||||
const w = mountPanel({ stepFilter: "s19", stepFilterName: "Navigate to /web/login" });
|
||||
await w.find('[data-test="synthetics-evidence-clear-step-filter-btn"]').trigger("click");
|
||||
expect(w.emitted("clear-step-filter")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no step-filter banner when unfiltered", () => {
|
||||
expect(mountPanel().find('[data-test="synthetics-evidence-step-filter"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a failed load instead of rendering a quiet run", () => {
|
||||
const w = mountPanel({ status: "error", error: "403 Forbidden", events: [] });
|
||||
expect(w.find('[data-test="synthetics-evidence-error"]').text()).toContain("403");
|
||||
});
|
||||
|
||||
it("asks the owner to retry rather than refetching itself", async () => {
|
||||
const w = mountPanel({ status: "error", error: "403 Forbidden", events: [] });
|
||||
await w.find('[data-test="synthetics-evidence-retry-btn"]').trigger("click");
|
||||
expect(w.emitted("retry")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("distinguishes capture-off from not-kept from absent", () => {
|
||||
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();
|
||||
it("states truncation rather than showing a quietly short list", () => {
|
||||
const w = mountPanel({ truncated: true });
|
||||
expect(w.find('[data-test="synthetics-evidence-truncated"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("labels the download as NDJSON", async () => {
|
||||
it("labels the download as NDJSON", () => {
|
||||
const w = mountPanel();
|
||||
await flushPromises();
|
||||
expect(w.find('[data-test="synthetics-evidence-download"]').text()).toContain(".ndjson");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EvidencePanel filter chips", () => {
|
||||
const named = () =>
|
||||
parseEvidenceNdjson(NDJSON).map((e) => ({
|
||||
...e,
|
||||
stepName: e.stepId ? STEP_DEFS.get(e.stepId)?.name || e.stepId : null,
|
||||
}));
|
||||
|
||||
const mountPanel = () =>
|
||||
mount(EvidencePanel, {
|
||||
props: {
|
||||
evidenceKey: "k",
|
||||
resolveUrl: (k: string) => k,
|
||||
stepDefs: STEP_DEFS,
|
||||
events: named(),
|
||||
status: "ready",
|
||||
error: null,
|
||||
},
|
||||
global: { plugins: [i18n] },
|
||||
});
|
||||
|
||||
it("narrows the list when a chip is chosen", async () => {
|
||||
const w = mountPanel();
|
||||
await w.find('[data-test="synthetics-evidence-chip-consoleErrors"]').trigger("click");
|
||||
await w.vm.$nextTick();
|
||||
// The console group survives the filter; the network group does not.
|
||||
expect(w.find('[data-test="synthetics-evidence-group-console"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="synthetics-evidence-group-network"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the selection when the active chip is clicked again", async () => {
|
||||
// Swapping the bare buttons for OToggleGroup put a deselect-on-reclick
|
||||
// behaviour within reach. It does not fire here, and it must not start to:
|
||||
// an empty filter would blank the chip row and silently show everything,
|
||||
// a state the old buttons could never reach.
|
||||
const w = mountPanel();
|
||||
const chip = '[data-test="synthetics-evidence-chip-consoleErrors"]';
|
||||
await w.find(chip).trigger("click");
|
||||
await w.vm.$nextTick();
|
||||
await w.find(chip).trigger("click");
|
||||
await w.vm.$nextTick();
|
||||
expect(w.find('[data-test="synthetics-evidence-group-console"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="synthetics-evidence-group-network"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns to the whole run when All is chosen", async () => {
|
||||
const w = mountPanel();
|
||||
await w.find('[data-test="synthetics-evidence-chip-consoleErrors"]').trigger("click");
|
||||
await w.vm.$nextTick();
|
||||
await w.find('[data-test="synthetics-evidence-chip-all"]').trigger("click");
|
||||
await w.vm.$nextTick();
|
||||
expect(w.find('[data-test="synthetics-evidence-group-network"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,81 +28,79 @@
|
|||
* 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 { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import {
|
||||
foldEvidenceBundle,
|
||||
parseEvidenceNdjson,
|
||||
type EvidenceEvent,
|
||||
} from "@/composables/synthetics/syntheticResultsSchema";
|
||||
import EvidenceEvents from "./EvidenceEvents.vue";
|
||||
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
|
||||
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
import type { EvidenceStatus } from "@/composables/useSyntheticEvidence";
|
||||
|
||||
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 props = withDefaults(
|
||||
defineProps<{
|
||||
/** Object-storage key of the selected attempt's bundle. Null when none exists. */
|
||||
evidenceKey: string | null;
|
||||
/** Resolves a key to a fetchable URL — used for the download link only. */
|
||||
resolveUrl: (key: string) => string;
|
||||
/** step_id -> definition, for naming the step on each row. */
|
||||
stepDefs: Map<string, { name: string; selector: string | null }>;
|
||||
/** The attempt's events, already fetched and named by useSyntheticEvidence. */
|
||||
events: EvidenceEvent[];
|
||||
status: EvidenceStatus;
|
||||
error: string | null;
|
||||
/** `evidence_truncated` from the record, or a truncation event in the stream. */
|
||||
truncated?: boolean;
|
||||
/** Scope every row and every count to one step. Null shows the whole run. */
|
||||
stepFilter?: string | null;
|
||||
/** Display name for the filtered step, for the banner. */
|
||||
stepFilterName?: string;
|
||||
/** 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;
|
||||
}>(),
|
||||
{
|
||||
truncated: false,
|
||||
stepFilter: null,
|
||||
stepFilterName: "",
|
||||
captureOff: false,
|
||||
runPassed: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: "clear-step-filter"): void; (e: "retry"): void }>();
|
||||
|
||||
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>());
|
||||
|
||||
const loading = computed(() => props.status === "loading" || props.status === "idle");
|
||||
const loadError = computed(() => (props.status === "error" ? props.error : null));
|
||||
|
||||
/**
|
||||
* Fetched on demand, not with the record: the bundle runs to 256 KB at the cap
|
||||
* and most users never open this tab.
|
||||
* Events after the STEP filter, before the kind filter.
|
||||
*
|
||||
* The chips are folded from this, not from the whole run: a chip that counts
|
||||
* the run while the list shows one step is a lie, not a shortcut.
|
||||
*/
|
||||
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 scopedEvents = computed(() =>
|
||||
props.stepFilter ? props.events.filter((e) => e.stepId === props.stepFilter) : props.events,
|
||||
);
|
||||
|
||||
const bundle = computed(() =>
|
||||
foldEvidenceBundle(events.value, props.stepDefs, props.recordTruncated ?? false),
|
||||
foldEvidenceBundle(scopedEvents.value, props.stepDefs, props.truncated),
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -168,36 +166,6 @@ const chips = computed(() => {
|
|||
];
|
||||
});
|
||||
|
||||
/** 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>
|
||||
|
||||
|
|
@ -236,22 +204,51 @@ const downloadUrl = computed(() => (props.evidenceKey ? props.resolveUrl(props.e
|
|||
<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
|
||||
<!-- A failed fetch is reported, never rendered as an empty run. The owner
|
||||
holds the bundle, so retrying is its call, not this panel's. -->
|
||||
<OBanner
|
||||
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"
|
||||
variant="error"
|
||||
dense
|
||||
inline-actions
|
||||
:content="t('synthetics.evidence.loadFailed', { error: loadError })"
|
||||
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 #actions>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
data-test="synthetics-evidence-retry-btn"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ t("synthetics.evidence.retry") }}
|
||||
</OButton>
|
||||
</template>
|
||||
</OBanner>
|
||||
|
||||
<template v-else>
|
||||
<!-- Scoped to one step, arrived at from a step expansion. Dismissible,
|
||||
because the run-level view is the other half of the question. -->
|
||||
<OBanner
|
||||
v-if="stepFilter"
|
||||
variant="info"
|
||||
dense
|
||||
inline-actions
|
||||
:content="t('synthetics.evidence.stepFilterBanner', { step: stepFilterName })"
|
||||
data-test="synthetics-evidence-step-filter"
|
||||
>
|
||||
<template #actions>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
data-test="synthetics-evidence-clear-step-filter-btn"
|
||||
@click="emit('clear-step-filter')"
|
||||
>
|
||||
{{ t("synthetics.evidence.clearStepFilter") }}
|
||||
</OButton>
|
||||
</template>
|
||||
</OBanner>
|
||||
|
||||
<!-- X-8.2: reduced fidelity is reported. A silently short list reads as a
|
||||
quiet run. -->
|
||||
<div
|
||||
|
|
@ -267,28 +264,25 @@ const downloadUrl = computed(() => (props.evidenceKey ? props.resolveUrl(props.e
|
|||
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>
|
||||
<OToggleGroup v-model="filter" type="single">
|
||||
<OToggleGroupItem
|
||||
v-for="c in chips"
|
||||
:key="c.key"
|
||||
:value="c.key"
|
||||
size="xs"
|
||||
:class="c.count === 0 && c.key !== 'all' ? 'opacity-50' : ''"
|
||||
:data-test="`synthetics-evidence-chip-${c.key}`"
|
||||
>
|
||||
{{ c.label }} {{ c.count }}
|
||||
</OToggleGroupItem>
|
||||
</OToggleGroup>
|
||||
<OCheckbox
|
||||
v-model="firstPartyOnly"
|
||||
size="sm"
|
||||
:label="t('synthetics.evidence.firstPartyOnly')"
|
||||
class="ml-2"
|
||||
data-test="synthetics-evidence-first-party"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!bundle.counts.all" class="text-text-secondary text-sm">
|
||||
|
|
@ -309,42 +303,15 @@ const downloadUrl = computed(() => (props.evidenceKey ? props.resolveUrl(props.e
|
|||
<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>
|
||||
<!-- Rows come from the shared table, so the step expansion and this
|
||||
panel cannot drift apart. Attribution is kept on the row, just
|
||||
not as the grouping axis. -->
|
||||
<EvidenceEvents
|
||||
:events="g.events"
|
||||
mode="panel"
|
||||
:filtered="!!stepFilter"
|
||||
@clear-filters="emit('clear-step-filter')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// Run detail for protocol checks (http/tcp/tls/ssh) — flat result fields, a
|
||||
// timing waterfall, and assertion outcomes. No steps/screenshots/replay
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, vi, afterEach } from "vitest";
|
||||
import { mount, VueWrapper } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { useI18n } from "vue-i18n";
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useStore } from "vuex";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<!-- 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">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useStore } from "vuex";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
// 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, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
|
||||
import i18n from "@/locales";
|
||||
import StepPageActivity from "./StepPageActivity.vue";
|
||||
import type { EvidenceEvent } from "@/composables/synthetics/syntheticResultsSchema";
|
||||
|
||||
const ev = (i: number): EvidenceEvent => ({
|
||||
ts: i,
|
||||
stepId: "s4",
|
||||
kind: "response",
|
||||
level: null,
|
||||
text: null,
|
||||
message: null,
|
||||
stack: null,
|
||||
method: "GET",
|
||||
url: `https://app.dev/r${i}`,
|
||||
status: 200,
|
||||
resourceType: null,
|
||||
initiatedTs: i,
|
||||
durationMs: 10,
|
||||
firstParty: true,
|
||||
stepName: "Click Sign In",
|
||||
});
|
||||
|
||||
const mountBlock = (props: Record<string, unknown> = {}) =>
|
||||
mount(StepPageActivity, {
|
||||
props: {
|
||||
stepId: "s4",
|
||||
events: [ev(1), ev(2)],
|
||||
status: "ready",
|
||||
error: null,
|
||||
truncated: false,
|
||||
unattributedCount: 0,
|
||||
...props,
|
||||
},
|
||||
global: { plugins: [i18n] },
|
||||
});
|
||||
|
||||
describe("StepPageActivity", () => {
|
||||
it("caps the inline list at five rows", () => {
|
||||
// A live bundle held 136 events on one step; uncapped it destroys the timeline.
|
||||
const w = mountBlock({ events: Array.from({ length: 12 }, (_, i) => ev(i)) });
|
||||
expect(w.findAll('[data-test="synthetics-evidence-events-row"]')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("counts what it showed against what exists", () => {
|
||||
const w = mountBlock({ events: Array.from({ length: 12 }, (_, i) => ev(i)) });
|
||||
expect(w.find('[data-test="synthetics-step-page-activity-count"]').text()).toContain("5 of 12");
|
||||
});
|
||||
|
||||
it("marks the total as a floor when capture was truncated", () => {
|
||||
// A silently short list reads as a quiet run.
|
||||
const w = mountBlock({ events: [ev(1)], truncated: true });
|
||||
expect(w.find('[data-test="synthetics-step-page-activity-count"]').text()).toContain("+");
|
||||
});
|
||||
|
||||
it("offers the full list only when there is more than it showed", () => {
|
||||
expect(
|
||||
mountBlock({ events: [ev(1), ev(2)] })
|
||||
.find('[data-test="synthetics-step-page-activity-view-all-btn"]')
|
||||
.exists(),
|
||||
).toBe(false);
|
||||
expect(
|
||||
mountBlock({ events: Array.from({ length: 12 }, (_, i) => ev(i)) })
|
||||
.find('[data-test="synthetics-step-page-activity-view-all-btn"]')
|
||||
.exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("names the step it wants shown, so the panel can filter to it", async () => {
|
||||
const w = mountBlock({ events: Array.from({ length: 12 }, (_, i) => ev(i)) });
|
||||
await w.find('[data-test="synthetics-step-page-activity-view-all-btn"]').trigger("click");
|
||||
expect(w.emitted("view-all")).toEqual([["s4"]]);
|
||||
});
|
||||
|
||||
it("shows skeletons while the bundle is in flight", () => {
|
||||
const w = mountBlock({ status: "loading", events: [] });
|
||||
expect(w.find('[data-test="synthetics-step-page-activity-loading"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a failed load and offers a retry", async () => {
|
||||
const w = mountBlock({ status: "error", error: "403 Forbidden", events: [] });
|
||||
expect(w.find('[data-test="synthetics-step-page-activity-error"]').text()).toContain("403");
|
||||
await w.find('[data-test="synthetics-step-page-activity-retry-btn"]').trigger("click");
|
||||
expect(w.emitted("retry")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the unattributed remainder when this step's window was empty", () => {
|
||||
// Otherwise "this step shows nothing" reads as "the run was quiet" — and
|
||||
// attribution is sparse: a live 158-event bundle had two distinct step ids.
|
||||
const w = mountBlock({ events: [], unattributedCount: 158 });
|
||||
expect(w.find('[data-test="synthetics-step-page-activity-empty"]').text()).toContain("158");
|
||||
});
|
||||
|
||||
it("says only that the window was empty when nothing is unattributed either", () => {
|
||||
const w = mountBlock({ events: [], unattributedCount: 0 });
|
||||
const text = w.find('[data-test="synthetics-step-page-activity-empty"]').text();
|
||||
expect(text).toContain("No events in this step's window.");
|
||||
expect(text).not.toContain("0 events were not attributed");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<!-- 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/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
StepPageActivity — what the page was doing during ONE step.
|
||||
|
||||
The step expansion already says what the RUNNER experienced: the error, the
|
||||
screenshot, which locator candidates resolved, which settle signals fired. What
|
||||
it could not say is what the APPLICATION was doing at the same moment — that
|
||||
lived one tab away, in a list grouped by event kind, where the correlation the
|
||||
engineer arrived with had to be rebuilt by hand.
|
||||
|
||||
Capped at INLINE_EVIDENCE_LIMIT and ranked worst-first, because attribution is
|
||||
lopsided: a live 158-event bundle carried two distinct step ids, so one step's
|
||||
bucket can hold 136 rows. The full list stays one click away, filtered to this
|
||||
step — the step is a filter over the run log, not a second copy of it.
|
||||
|
||||
Separate from StepEvidence.vue, which is gated on the failure detail and so
|
||||
renders only on the step that failed. Page activity renders on ANY step that
|
||||
owns events: a 503 during a step that PASSED is routinely the cause of the
|
||||
timeout two steps later.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import EvidenceEvents from "./EvidenceEvents.vue";
|
||||
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
import { INLINE_EVIDENCE_LIMIT } from "@/constants/synthetics";
|
||||
import type { EvidenceStatus } from "@/composables/useSyntheticEvidence";
|
||||
import type { EvidenceEvent } from "@/composables/synthetics/syntheticResultsSchema";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
stepId: string;
|
||||
/** This step's bucket, already ranked worst-first. */
|
||||
events: EvidenceEvent[];
|
||||
status: EvidenceStatus;
|
||||
error: string | null;
|
||||
/** True when the capture cap bound during the run (X-8.2). */
|
||||
truncated?: boolean;
|
||||
/** Events the bundle could not attribute to any step. */
|
||||
unattributedCount?: number;
|
||||
}>(),
|
||||
{ truncated: false, unattributedCount: 0 },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: "view-all", stepId: string): void; (e: "retry"): void }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const shown = computed(() => props.events.slice(0, INLINE_EVIDENCE_LIMIT));
|
||||
const hasMore = computed(() => props.events.length > INLINE_EVIDENCE_LIMIT);
|
||||
|
||||
/**
|
||||
* "5 of 136", or "5 of 136+" when the cap bound during capture.
|
||||
*
|
||||
* The `+` is the whole point: a silently short list reads as a quiet run, which
|
||||
* is the opposite of what a truncated capture means.
|
||||
*/
|
||||
const countLabel = computed(() =>
|
||||
t(
|
||||
props.truncated
|
||||
? "synthetics.runDetail.pageActivityCountTruncated"
|
||||
: "synthetics.runDetail.pageActivityCount",
|
||||
{ shown: shown.value.length, total: props.events.length },
|
||||
),
|
||||
);
|
||||
|
||||
const isLoading = computed(() => props.status === "idle" || props.status === "loading");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OCollapsible
|
||||
:label="t('synthetics.runDetail.pageActivity')"
|
||||
:default-open="true"
|
||||
data-test="synthetics-step-page-activity"
|
||||
>
|
||||
<template #trigger>
|
||||
<span class="text-text-heading text-xs font-semibold">
|
||||
{{ t("synthetics.runDetail.pageActivity") }}
|
||||
</span>
|
||||
<span
|
||||
v-if="status === 'ready'"
|
||||
class="text-text-secondary text-2xs ml-2"
|
||||
data-test="synthetics-step-page-activity-count"
|
||||
>
|
||||
{{ countLabel }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex flex-col gap-1"
|
||||
data-test="synthetics-step-page-activity-loading"
|
||||
>
|
||||
<OSkeleton v-for="i in 3" :key="i" type="text" class="h-4 w-full" />
|
||||
</div>
|
||||
|
||||
<!-- A failed fetch is reported, never rendered as an empty step. -->
|
||||
<OBanner
|
||||
v-else-if="status === 'error'"
|
||||
variant="error"
|
||||
dense
|
||||
inline-actions
|
||||
:content="t('synthetics.evidence.loadFailed', { error: error ?? '' })"
|
||||
data-test="synthetics-step-page-activity-error"
|
||||
>
|
||||
<template #actions>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
data-test="synthetics-step-page-activity-retry-btn"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ t("synthetics.evidence.retry") }}
|
||||
</OButton>
|
||||
</template>
|
||||
</OBanner>
|
||||
|
||||
<!-- Empty is a finding, and a lonely one: attribution is sparse, so naming
|
||||
the unattributed remainder is what stops this reading as "the run was
|
||||
quiet". -->
|
||||
<p
|
||||
v-else-if="!events.length"
|
||||
class="text-text-secondary m-0 text-xs"
|
||||
data-test="synthetics-step-page-activity-empty"
|
||||
>
|
||||
{{ t("synthetics.evidence.noEventsInStep") }}
|
||||
<span v-if="unattributedCount > 0">
|
||||
{{ t("synthetics.evidence.unattributedCount", { count: unattributedCount }) }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<EvidenceEvents :events="shown" mode="inline" />
|
||||
<div v-if="hasMore" class="flex">
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon-right="arrow-forward"
|
||||
data-test="synthetics-step-page-activity-view-all-btn"
|
||||
@click="emit('view-all', stepId)"
|
||||
>
|
||||
{{ t("synthetics.runDetail.pageActivityViewAll", { count: events.length }) }}
|
||||
</OButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</OCollapsible>
|
||||
</template>
|
||||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, it, expect } from "vitest";
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ import {
|
|||
mapHistogram,
|
||||
mapKpi,
|
||||
mapRun,
|
||||
evidenceSeverity,
|
||||
indexEvidenceByStep,
|
||||
stepOwnDetail,
|
||||
mergeLocatorLadder,
|
||||
type EvidenceEvent,
|
||||
} from "./syntheticResultsSchema";
|
||||
|
||||
describe("syntheticResultsSchema query builders", () => {
|
||||
|
|
@ -904,3 +909,355 @@ describe("run timing breakdown", () => {
|
|||
expect(run.queueDelayMs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Evidence ranking and step attribution ──────────────────────────────────
|
||||
|
||||
/** A minimal event; every field the shape requires, overridable per case. */
|
||||
const evidenceEv = (over: Partial<EvidenceEvent>): EvidenceEvent => ({
|
||||
ts: 0,
|
||||
stepId: "s1",
|
||||
kind: "response",
|
||||
level: null,
|
||||
text: null,
|
||||
message: null,
|
||||
stack: null,
|
||||
method: null,
|
||||
url: null,
|
||||
status: null,
|
||||
resourceType: null,
|
||||
initiatedTs: null,
|
||||
durationMs: null,
|
||||
firstParty: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("evidence severity ranking", () => {
|
||||
it("ranks a page error above a failed request above a console error", () => {
|
||||
expect(evidenceSeverity(evidenceEv({ kind: "pageerror" }))).toBeLessThan(
|
||||
evidenceSeverity(evidenceEv({ kind: "requestfailed" })),
|
||||
);
|
||||
expect(evidenceSeverity(evidenceEv({ kind: "requestfailed" }))).toBeLessThan(
|
||||
evidenceSeverity(evidenceEv({ kind: "console", level: "error" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("ranks a 5xx above a 4xx above a healthy response", () => {
|
||||
expect(evidenceSeverity(evidenceEv({ status: 503 }))).toBeLessThan(
|
||||
evidenceSeverity(evidenceEv({ status: 401 })),
|
||||
);
|
||||
expect(evidenceSeverity(evidenceEv({ status: 401 }))).toBeLessThan(
|
||||
evidenceSeverity(evidenceEv({ status: 200 })),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a crash as a page error, not as an unranked kind", () => {
|
||||
expect(evidenceSeverity(evidenceEv({ kind: "crash" }))).toBe(
|
||||
evidenceSeverity(evidenceEv({ kind: "pageerror" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not promote a non-error console line", () => {
|
||||
// A console.log is not a finding; it must not outrank a 404.
|
||||
expect(evidenceSeverity(evidenceEv({ kind: "console", level: "log" }))).toBeGreaterThan(
|
||||
evidenceSeverity(evidenceEv({ status: 404 })),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("indexEvidenceByStep", () => {
|
||||
it("buckets events under their own step", () => {
|
||||
const { byStep } = indexEvidenceByStep([
|
||||
evidenceEv({ stepId: "s1" }),
|
||||
evidenceEv({ stepId: "s2" }),
|
||||
evidenceEv({ stepId: "s1" }),
|
||||
]);
|
||||
expect(byStep.get("s1")).toHaveLength(2);
|
||||
expect(byStep.get("s2")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns unattributed events separately rather than under a null key", () => {
|
||||
// "we could not attribute this" is a finding, not a step.
|
||||
const { byStep, unattributed } = indexEvidenceByStep([
|
||||
evidenceEv({ stepId: null }),
|
||||
evidenceEv({ stepId: "s1" }),
|
||||
]);
|
||||
expect(unattributed).toHaveLength(1);
|
||||
expect(byStep.has("s1")).toBe(true);
|
||||
expect(byStep.size).toBe(1);
|
||||
});
|
||||
|
||||
it("ranks each bucket by severity, worst first", () => {
|
||||
const { byStep } = indexEvidenceByStep([
|
||||
evidenceEv({ status: 200 }),
|
||||
evidenceEv({ kind: "pageerror" }),
|
||||
evidenceEv({ status: 503 }),
|
||||
]);
|
||||
expect(
|
||||
byStep.get("s1")!.map((e) => (e.kind === "pageerror" ? "page" : String(e.status))),
|
||||
).toEqual(["page", "503", "200"]);
|
||||
});
|
||||
|
||||
it("ranks first-party above third-party at equal severity", () => {
|
||||
// Third-party is ranked below, never dropped (design D6).
|
||||
const { byStep } = indexEvidenceByStep([
|
||||
evidenceEv({ status: 503, firstParty: false, url: "https://cdn.example.io/a" }),
|
||||
evidenceEv({ status: 503, firstParty: true, url: "https://app.example.dev/b" }),
|
||||
]);
|
||||
expect(byStep.get("s1")!.map((e) => e.firstParty)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("orders equal-severity, equal-party events by when they were initiated", () => {
|
||||
const { byStep } = indexEvidenceByStep([
|
||||
evidenceEv({ status: 200, initiatedTs: 300 }),
|
||||
evidenceEv({ status: 200, initiatedTs: 100 }),
|
||||
evidenceEv({ status: 200, initiatedTs: 200 }),
|
||||
]);
|
||||
expect(byStep.get("s1")!.map((e) => e.initiatedTs)).toEqual([100, 200, 300]);
|
||||
});
|
||||
|
||||
it("falls back to the observed timestamp when nothing was initiated", () => {
|
||||
const { byStep } = indexEvidenceByStep([
|
||||
evidenceEv({ kind: "console", level: "log", ts: 500 }),
|
||||
evidenceEv({ kind: "console", level: "log", ts: 100 }),
|
||||
]);
|
||||
expect(byStep.get("s1")!.map((e) => e.ts)).toEqual([100, 500]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Per-step settle attribution ────────────────────────────────────────────
|
||||
//
|
||||
// Shapes copied from a live "OpenObserve Sys Query" failure (run 3HFCZ3fm…),
|
||||
// where failure_detail carried the whole journey's 13 signals while the failed
|
||||
// step owned none of them.
|
||||
describe("stepOwnDetail", () => {
|
||||
const RUN_LEVEL = {
|
||||
stepId: "s15",
|
||||
stepName: "Click on synthetics_results",
|
||||
stepIndex: 15,
|
||||
error: "did not become visible within 30000 ms",
|
||||
candidatesTried: [],
|
||||
settleSignals: [
|
||||
{
|
||||
kind: "response" as const,
|
||||
signal: "POST **/auth/login",
|
||||
status: "fired" as const,
|
||||
required: false,
|
||||
waitedMs: 5298,
|
||||
},
|
||||
{
|
||||
kind: "navigation" as const,
|
||||
signal: "navigation to **/web/logs/**",
|
||||
status: "fired" as const,
|
||||
required: false,
|
||||
waitedMs: 2532,
|
||||
},
|
||||
{
|
||||
kind: "response" as const,
|
||||
signal: "POST **/_search_stream",
|
||||
status: "stale" as const,
|
||||
required: false,
|
||||
waitedMs: 30274,
|
||||
},
|
||||
],
|
||||
settleMs: 30274,
|
||||
observedDurationMs: 1200,
|
||||
screenshotKey: null,
|
||||
traceKey: null,
|
||||
};
|
||||
|
||||
const step = (over: any) => ({
|
||||
step_id: "s14",
|
||||
status: "ok" as const,
|
||||
duration_ms: 30285,
|
||||
error: null,
|
||||
start_time: 0,
|
||||
end_time: 1,
|
||||
screenshot_key: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("gives the failing step only the signals it actually owns", () => {
|
||||
// The run-level list is an accumulation across every step; rendering it on
|
||||
// the failed step credits it with traffic from steps it never ran.
|
||||
const d = stepOwnDetail(
|
||||
step({ step_id: "s15", status: "fail", settle_signals: [] }),
|
||||
RUN_LEVEL,
|
||||
);
|
||||
expect(d?.settleSignals).toEqual([]);
|
||||
expect(d?.error).toBe("did not become visible within 30000 ms");
|
||||
});
|
||||
|
||||
it("puts a stale signal on the step that produced it", () => {
|
||||
const d = stepOwnDetail(
|
||||
step({
|
||||
settle_signals: [
|
||||
{
|
||||
kind: "response",
|
||||
signal: "POST **/_search_stream",
|
||||
status: "stale",
|
||||
required: false,
|
||||
waited_ms: 30274,
|
||||
},
|
||||
],
|
||||
}),
|
||||
RUN_LEVEL,
|
||||
);
|
||||
expect(d?.settleSignals).toHaveLength(1);
|
||||
expect(d?.settleSignals[0]).toMatchObject({ status: "stale", waitedMs: 30274 });
|
||||
});
|
||||
|
||||
it("normalises waited_ms the same way the run-level mapper does", () => {
|
||||
const d = stepOwnDetail(
|
||||
step({
|
||||
settle_signals: [{ kind: "navigation", signal: "nav", status: "fired", waited_ms: 46 }],
|
||||
}),
|
||||
RUN_LEVEL,
|
||||
);
|
||||
expect(d?.settleSignals[0].waitedMs).toBe(46);
|
||||
expect(d?.settleSignals[0].required).toBe(false);
|
||||
});
|
||||
|
||||
it("carries the step's own locator candidates", () => {
|
||||
const d = stepOwnDetail(
|
||||
step({ candidates_tried: [{ kind: "role", value: "button", outcome: "matched" }] }),
|
||||
RUN_LEVEL,
|
||||
);
|
||||
expect(d?.candidatesTried).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns null for a passing step with nothing of its own to show", () => {
|
||||
// Otherwise every row grows an empty evidence block.
|
||||
expect(stepOwnDetail(step({}), RUN_LEVEL)).toBeNull();
|
||||
});
|
||||
|
||||
it("never borrows the run-level error for a step that did not fail", () => {
|
||||
const d = stepOwnDetail(
|
||||
step({
|
||||
settle_signals: [{ kind: "navigation", signal: "nav", status: "fired", waited_ms: 1 }],
|
||||
}),
|
||||
RUN_LEVEL,
|
||||
);
|
||||
expect(d?.error).toBe("");
|
||||
});
|
||||
|
||||
it("works when there is no run-level failure detail at all", () => {
|
||||
const d = stepOwnDetail(
|
||||
step({
|
||||
settle_signals: [{ kind: "navigation", signal: "nav", status: "fired", waited_ms: 1 }],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
expect(d?.settleSignals).toHaveLength(1);
|
||||
expect(d?.stepId).toBe("s14");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Locator ladder ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Shapes from the live "OpenObserve Cloud Happy Path" failure: step 16 asserts
|
||||
// a single role locator and reports `used_as_primary`, while its click steps
|
||||
// carry two or three candidates. Nothing on the record said which was which.
|
||||
describe("mergeLocatorLadder", () => {
|
||||
const authored = [
|
||||
{ kind: "test_attribute", value: 'internal:testid=[data-test="x"]' },
|
||||
{ kind: "role", value: "internal:role=button" },
|
||||
{ kind: "css", value: "#submit" },
|
||||
];
|
||||
|
||||
it("marks authored candidates the probe never reached as not_tried", () => {
|
||||
// `not_tried` has been in the union since the type was written and nothing
|
||||
// ever produced it — this is the case it was for.
|
||||
const ladder = mergeLocatorLadder(
|
||||
[{ kind: "test_attribute", value: 'internal:testid=[data-test="x"]', outcome: "matched" }],
|
||||
authored,
|
||||
);
|
||||
expect(ladder.map((c) => c.outcome)).toEqual(["matched", "not_tried", "not_tried"]);
|
||||
});
|
||||
|
||||
it("keeps the authored order, which is the order the probe walks them", () => {
|
||||
const ladder = mergeLocatorLadder([], authored);
|
||||
expect(ladder.map((c) => c.kind)).toEqual(["test_attribute", "role", "css"]);
|
||||
});
|
||||
|
||||
it("pairs an outcome to its candidate by value, not by position", () => {
|
||||
// A probe that skips a rung would otherwise shift every outcome up one.
|
||||
const ladder = mergeLocatorLadder(
|
||||
[{ kind: "css", value: "#submit", outcome: "matched" }],
|
||||
authored,
|
||||
);
|
||||
expect(ladder[2]).toMatchObject({ value: "#submit", outcome: "matched" });
|
||||
expect(ladder[0].outcome).toBe("not_tried");
|
||||
});
|
||||
|
||||
it("falls back to what was tried when the record carries no authored bundle", () => {
|
||||
// Older records predate the locator bundle; showing nothing would be worse
|
||||
// than showing the attempts.
|
||||
const tried = [{ kind: "role", value: "r", outcome: "used_as_primary" as const }];
|
||||
expect(mergeLocatorLadder(tried, undefined)).toEqual(tried);
|
||||
expect(mergeLocatorLadder(tried, [])).toEqual(tried);
|
||||
});
|
||||
|
||||
it("keeps an attempted candidate the authored bundle does not list", () => {
|
||||
// Self-healing may reach for something not in the bundle; dropping it would
|
||||
// hide the locator that actually ran.
|
||||
const ladder = mergeLocatorLadder(
|
||||
[{ kind: "role", value: "surprise", outcome: "matched" }],
|
||||
authored,
|
||||
);
|
||||
expect(ladder.some((c) => c.value === "surprise")).toBe(true);
|
||||
expect(ladder).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("returns nothing when there is neither a bundle nor an attempt", () => {
|
||||
expect(mergeLocatorLadder([], undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepOwnDetail locator ladder", () => {
|
||||
const step = (over: any) => ({
|
||||
step_id: "s16",
|
||||
status: "fail" as const,
|
||||
duration_ms: 60000,
|
||||
error: null,
|
||||
start_time: 0,
|
||||
end_time: 1,
|
||||
screenshot_key: null,
|
||||
...over,
|
||||
});
|
||||
const RUN = {
|
||||
stepId: "s16",
|
||||
stepName: "Assert visible row",
|
||||
stepIndex: 16,
|
||||
error: "Timeout 60000ms exceeded",
|
||||
candidatesTried: [],
|
||||
settleSignals: [],
|
||||
settleMs: null,
|
||||
observedDurationMs: null,
|
||||
screenshotKey: null,
|
||||
traceKey: null,
|
||||
};
|
||||
|
||||
it("shows the whole authored ladder, not only what ran", () => {
|
||||
const d = stepOwnDetail(
|
||||
step({ candidates_tried: [{ kind: "role", value: "a", outcome: "used_as_primary" }] }),
|
||||
RUN,
|
||||
[
|
||||
{ kind: "role", value: "a" },
|
||||
{ kind: "css", value: "b" },
|
||||
],
|
||||
);
|
||||
expect(d?.candidatesTried).toHaveLength(2);
|
||||
expect(d?.candidatesTried[1].outcome).toBe("not_tried");
|
||||
});
|
||||
|
||||
it("reports a one-rung ladder as exactly that", () => {
|
||||
// The Happy Path failure: one authored candidate, so no fallback existed.
|
||||
const d = stepOwnDetail(
|
||||
step({ candidates_tried: [{ kind: "role", value: "a", outcome: "used_as_primary" }] }),
|
||||
RUN,
|
||||
[{ kind: "role", value: "a" }],
|
||||
);
|
||||
expect(d?.candidatesTried).toHaveLength(1);
|
||||
expect(d?.candidatesTried[0].outcome).toBe("used_as_primary");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -257,6 +257,17 @@ export interface RecordedStep {
|
|||
value: string | null;
|
||||
key: string | null;
|
||||
text: string | null;
|
||||
/**
|
||||
* The locator bundle as authored — every candidate the probe MAY walk, in
|
||||
* the order it walks them.
|
||||
*
|
||||
* `candidates_tried` on the result says what ran; this says what was
|
||||
* available. The difference is the whole question "was a fallback tried?":
|
||||
* one authored candidate means no fallback existed, which reads very
|
||||
* differently from three authored and the first one matching. The mapper has
|
||||
* always passed this through untouched — only the type omitted it.
|
||||
*/
|
||||
locator?: { candidates?: Array<{ kind: string; value: string; origin?: string }> };
|
||||
}
|
||||
|
||||
export interface StepExecution {
|
||||
|
|
@ -267,6 +278,15 @@ export interface StepExecution {
|
|||
start_time: number;
|
||||
end_time: number;
|
||||
screenshot_key: string | null;
|
||||
/**
|
||||
* This step's OWN settle signals and locator candidates, as the probe wrote
|
||||
* them. Declared because the record has carried them all along — the mapper
|
||||
* spreads the raw step — while only the run-level `failure_detail` copy was
|
||||
* ever read, which credited the failing step with the whole journey's traffic.
|
||||
*/
|
||||
settle_signals?: unknown[];
|
||||
candidates_tried?: unknown[];
|
||||
settle_ms?: number;
|
||||
}
|
||||
|
||||
export interface RetryAttempt {
|
||||
|
|
@ -559,6 +579,162 @@ export function foldEvidenceBundle(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity rank, 1 (worst) … 6. Lower sorts first.
|
||||
*
|
||||
* Ordering is the guidance — there is no verdict anywhere in this feature, so
|
||||
* "what to read first" is the only steer the UI gives (design NG1).
|
||||
*/
|
||||
export function evidenceSeverity(e: EvidenceEvent): number {
|
||||
if (e.kind === "pageerror" || e.kind === "crash") return 1;
|
||||
if (e.kind === "requestfailed") return 2;
|
||||
if (e.kind === "console" && e.level === "error") return 3;
|
||||
if (e.kind === "response") {
|
||||
const s = e.status ?? 0;
|
||||
if (s >= 500) return 4;
|
||||
if (s >= 400) return 5;
|
||||
}
|
||||
return 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity, then first-party, then initiation order.
|
||||
*
|
||||
* Third-party is ranked BELOW first-party and never dropped: a failing
|
||||
* third-party script is a legitimate cause of a broken page, and ordering
|
||||
* degrades gracefully where a filter would discard evidence silently (X-8.2).
|
||||
*/
|
||||
function byEvidenceRank(a: EvidenceEvent, b: EvidenceEvent): number {
|
||||
const sev = evidenceSeverity(a) - evidenceSeverity(b);
|
||||
if (sev !== 0) return sev;
|
||||
if (a.firstParty !== b.firstParty) return a.firstParty ? -1 : 1;
|
||||
return (a.initiatedTs ?? a.ts) - (b.initiatedTs ?? b.ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a bundle by step, each bucket ranked worst-first.
|
||||
*
|
||||
* Unattributed events come back SEPARATELY rather than under a null key.
|
||||
* Bucketing is best-effort — a live 158-event bundle carried only two distinct
|
||||
* `step_id`s — so "we could not attribute this" is a finding the caller must be
|
||||
* able to state, not a step it can quietly index.
|
||||
*/
|
||||
/** Normalise one raw `settle_signals` entry. Shared by both call sites. */
|
||||
function mapSettleSignal(sig: any): SettleSignal {
|
||||
return {
|
||||
kind: sig?.kind,
|
||||
signal: str(sig?.signal),
|
||||
status: sig?.status,
|
||||
required: !!sig?.required,
|
||||
waitedMs: typeof sig?.waited_ms === "number" ? sig.waited_ms : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The evidence one step owns, as opposed to the run's.
|
||||
*
|
||||
* `failure_detail.settle_signals` is an ACCUMULATION across the whole journey:
|
||||
* on a live 16-step failure it held 13 signals — one from step 2, six from step
|
||||
* 8, two from step 14 — and the failing step owned none of them. Rendering that
|
||||
* list under the failed step reads as "step 15 waited on /auth/login", which the
|
||||
* page never did; the signal that actually went stale belonged to step 14.
|
||||
*
|
||||
* So attribution comes from the step's own row, and only the narrative fields
|
||||
* (the error, the recorded baseline) come from `failure_detail`, and only for
|
||||
* the step that failed.
|
||||
*
|
||||
* Returns null when the step has nothing of its own to show, so a passing step
|
||||
* does not grow an empty evidence block.
|
||||
*/
|
||||
/**
|
||||
* The full locator ladder: every authored candidate, annotated with what
|
||||
* happened to it.
|
||||
*
|
||||
* `candidates_tried` lists only the rungs the probe actually stood on, so a
|
||||
* step that matched on the first candidate looks identical to a step that had
|
||||
* no other candidate to try. Both render as one row, and the reader cannot tell
|
||||
* "the primary worked" from "there was nothing else". Folding the authored
|
||||
* bundle back in makes the untried rungs visible as `not_tried` — a value the
|
||||
* type has always allowed and nothing ever produced.
|
||||
*
|
||||
* Paired by VALUE, not by position: a probe that skips a rung would otherwise
|
||||
* shift every outcome onto the wrong candidate.
|
||||
*/
|
||||
export function mergeLocatorLadder(
|
||||
tried: LocatorAttempt[],
|
||||
authored: Array<{ kind: string; value: string }> | undefined,
|
||||
): LocatorAttempt[] {
|
||||
// No bundle on the record (it predates them) — the attempts are all we know.
|
||||
if (!authored?.length) return tried;
|
||||
|
||||
const byValue = new Map(tried.map((c) => [c.value, c]));
|
||||
const ladder: LocatorAttempt[] = authored.map(
|
||||
(a) => byValue.get(a.value) ?? { kind: a.kind, value: a.value, outcome: "not_tried" },
|
||||
);
|
||||
|
||||
// Anything the probe reached for that the bundle does not list — self-healing
|
||||
// can go outside the bundle, and dropping it would hide the locator that ran.
|
||||
const authoredValues = new Set(authored.map((a) => a.value));
|
||||
for (const c of tried) {
|
||||
if (!authoredValues.has(c.value)) ladder.push(c);
|
||||
}
|
||||
return ladder;
|
||||
}
|
||||
|
||||
export function stepOwnDetail(
|
||||
ex: StepExecution,
|
||||
failureDetail: FailureDetail | null,
|
||||
/** The step's authored locator bundle, so untried rungs stay visible. */
|
||||
authoredCandidates?: Array<{ kind: string; value: string }>,
|
||||
): FailureDetail | null {
|
||||
const isFailing = !!failureDetail && failureDetail.stepId === ex.step_id;
|
||||
const signals = Array.isArray(ex.settle_signals) ? ex.settle_signals.map(mapSettleSignal) : [];
|
||||
const candidates = mergeLocatorLadder(
|
||||
Array.isArray(ex.candidates_tried) ? (ex.candidates_tried as LocatorAttempt[]) : [],
|
||||
authoredCandidates,
|
||||
);
|
||||
const settleMs = typeof ex.settle_ms === "number" ? ex.settle_ms : null;
|
||||
|
||||
if (!isFailing && !signals.length && !candidates.length) return null;
|
||||
|
||||
return {
|
||||
stepId: ex.step_id,
|
||||
stepName: isFailing ? failureDetail!.stepName : "",
|
||||
stepIndex: isFailing ? failureDetail!.stepIndex : 0,
|
||||
// Only the failing step gets the error — it is the one statement on the
|
||||
// record that describes a failure, and it describes exactly one.
|
||||
error: isFailing ? failureDetail!.error : "",
|
||||
candidatesTried: candidates,
|
||||
settleSignals: signals,
|
||||
settleMs,
|
||||
// The recorded baseline is written per failure, not per step, so it stays
|
||||
// with the failing step rather than being invented for its neighbours.
|
||||
observedDurationMs: isFailing ? failureDetail!.observedDurationMs : null,
|
||||
screenshotKey: isFailing ? failureDetail!.screenshotKey : null,
|
||||
traceKey: isFailing ? failureDetail!.traceKey : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function indexEvidenceByStep(events: EvidenceEvent[]): {
|
||||
byStep: Map<string, EvidenceEvent[]>;
|
||||
unattributed: EvidenceEvent[];
|
||||
} {
|
||||
const byStep = new Map<string, EvidenceEvent[]>();
|
||||
const unattributed: EvidenceEvent[] = [];
|
||||
for (const e of events) {
|
||||
if (!e.stepId) {
|
||||
unattributed.push(e);
|
||||
continue;
|
||||
}
|
||||
const list = byStep.get(e.stepId);
|
||||
if (list) list.push(e);
|
||||
else byStep.set(e.stepId, [e]);
|
||||
}
|
||||
for (const list of byStep.values()) list.sort(byEvidenceRank);
|
||||
unattributed.sort(byEvidenceRank);
|
||||
return { byStep, unattributed };
|
||||
}
|
||||
|
||||
export interface NetworkStats {
|
||||
requests: number;
|
||||
failed: number;
|
||||
|
|
@ -1528,15 +1704,7 @@ function mapFailureDetail(raw: unknown, recordTraceKey?: unknown): FailureDetail
|
|||
stepIndex: typeof d.step_index === "number" ? d.step_index : 0,
|
||||
error: str(d.error),
|
||||
candidatesTried: Array.isArray(d.candidates_tried) ? d.candidates_tried : [],
|
||||
settleSignals: Array.isArray(d.settle_signals)
|
||||
? d.settle_signals.map((sig: any) => ({
|
||||
kind: sig?.kind,
|
||||
signal: str(sig?.signal),
|
||||
status: sig?.status,
|
||||
required: !!sig?.required,
|
||||
waitedMs: typeof sig?.waited_ms === "number" ? sig.waited_ms : 0,
|
||||
}))
|
||||
: [],
|
||||
settleSignals: Array.isArray(d.settle_signals) ? d.settle_signals.map(mapSettleSignal) : [],
|
||||
settleMs: typeof d.settle_ms === "number" ? d.settle_ms : null,
|
||||
observedDurationMs: typeof d.observed_duration_ms === "number" ? d.observed_duration_ms : null,
|
||||
screenshotKey: d.screenshot_key ? str(d.screenshot_key) : null,
|
||||
|
|
@ -1791,20 +1959,20 @@ function timeBucketKey(tsMs: number, bucketMs: number): number {
|
|||
}
|
||||
|
||||
/**
|
||||
* The selector to show for a recorded step, whichever schema version it uses.
|
||||
* The selector to show for a recorded step.
|
||||
*
|
||||
* A v1 step has one `selector`. A v2 step has a locator bundle instead, so
|
||||
* reading `selector` alone leaves every v2 run showing empty selectors in
|
||||
* results — a regression that would look like missing data rather than a schema
|
||||
* mismatch (spec P2.5.6).
|
||||
* A step's identity is its locator bundle, so reading a bare `selector` alone
|
||||
* left every run showing empty selectors in results — a regression that looked
|
||||
* like missing data rather than a schema mismatch (spec P2.5.6).
|
||||
*
|
||||
* A pinned `user_override` wins, because that is the locator the run actually
|
||||
* used; otherwise it is the primary candidate, which is what the run would have
|
||||
* started from.
|
||||
* Position 0: the author's first choice, and what the run started from. This
|
||||
* used to check a pin first. That branch is gone, and with it the third copy of
|
||||
* "pin, else primary" — the other two were in `crx` and in the step editor.
|
||||
* A stored pin, if one somehow exists, is ignored rather than honoured: the
|
||||
* server refuses to store one, so acting on it would mean showing a locator the
|
||||
* run could not have used.
|
||||
*/
|
||||
export function effectiveSelector(step: Record<string, any>): string | null {
|
||||
const pinned = step?.locator?.user_override;
|
||||
if (pinned?.value) return str(pinned.value);
|
||||
const primary = step?.locator?.candidates?.[0];
|
||||
if (primary?.value) return str(primary.value);
|
||||
return step?.selector ? str(step.selector) : null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
// 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, it, expect, vi, beforeEach } from "vitest";
|
||||
import { nextTick, ref } from "vue";
|
||||
import { useSyntheticEvidence } from "./useSyntheticEvidence";
|
||||
|
||||
const NDJSON = [
|
||||
'{"ts":100,"kind":"response","method":"GET","url":"https://app.dev/a","status":200,"initiated_ts":90,"duration_ms":10,"first_party":true,"step_id":"s1"}',
|
||||
'{"ts":200,"kind":"console","level":"error","text":"boom","step_id":"s1"}',
|
||||
'{"ts":300,"kind":"response","method":"GET","url":"https://app.dev/b","status":503,"initiated_ts":290,"first_party":true,"step_id":"s2"}',
|
||||
'{"ts":400,"kind":"console","level":"log","text":"unattributed line"}',
|
||||
].join("\n");
|
||||
|
||||
const STEP_DEFS = ref(
|
||||
new Map([
|
||||
["s1", { name: "Go to login", selector: null }],
|
||||
["s2", { name: "Click Sign In", selector: null }],
|
||||
]),
|
||||
);
|
||||
|
||||
function setup(key: string | null = "bundle.ndjson", truncated = false) {
|
||||
const evidenceKey = ref(key);
|
||||
const recordTruncated = ref(truncated);
|
||||
const api = useSyntheticEvidence(
|
||||
evidenceKey,
|
||||
(k: string) => `/artifact?key=${k}`,
|
||||
STEP_DEFS,
|
||||
recordTruncated,
|
||||
);
|
||||
return { evidenceKey, recordTruncated, ...api };
|
||||
}
|
||||
|
||||
describe("useSyntheticEvidence", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
text: async () => NDJSON,
|
||||
})) as any;
|
||||
});
|
||||
|
||||
it("fetches once no matter how many consumers ask", async () => {
|
||||
// Both the step expansion and the Evidence tab call load(); the bundle runs
|
||||
// to 256 KB at the cap and must not be fetched twice.
|
||||
const { load, status } = setup();
|
||||
await Promise.all([load(), load(), load()]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(status.value).toBe("ready");
|
||||
});
|
||||
|
||||
it("is a no-op once loaded", async () => {
|
||||
const { load } = setup();
|
||||
await load();
|
||||
await load();
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves each event's step name from the run's own snapshot", async () => {
|
||||
const { load, events } = setup();
|
||||
await load();
|
||||
expect(events.value.find((e) => e.stepId === "s1")?.stepName).toBe("Go to login");
|
||||
});
|
||||
|
||||
it("leaves an unattributed event's step name null rather than guessing", async () => {
|
||||
const { load, events } = setup();
|
||||
await load();
|
||||
expect(events.value.find((e) => e.stepId === null)?.stepName).toBeNull();
|
||||
});
|
||||
|
||||
it("indexes by step and counts what could not be attributed", async () => {
|
||||
const { load, eventsByStep, unattributedCount } = setup();
|
||||
await load();
|
||||
expect(eventsByStep.value.get("s1")).toHaveLength(2);
|
||||
expect(eventsByStep.value.get("s2")).toHaveLength(1);
|
||||
expect(unattributedCount.value).toBe(1);
|
||||
});
|
||||
|
||||
it("resets when the attempt changes, so bundles never cross labels", async () => {
|
||||
const { load, evidenceKey, status, events } = setup();
|
||||
await load();
|
||||
expect(events.value).toHaveLength(4);
|
||||
evidenceKey.value = "attempt-2-evidence.ndjson";
|
||||
await nextTick();
|
||||
expect(status.value).toBe("idle");
|
||||
expect(events.value).toHaveLength(0);
|
||||
await load();
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
expect((globalThis.fetch as any).mock.calls[1][0]).toContain("attempt-2-evidence.ndjson");
|
||||
});
|
||||
|
||||
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 { load, status, error, events } = setup();
|
||||
await load();
|
||||
expect(status.value).toBe("error");
|
||||
expect(error.value).toContain("403");
|
||||
expect(events.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("retries only when forced, since an error is otherwise settled", async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
text: async () => "",
|
||||
})) as any;
|
||||
const { load } = setup();
|
||||
await load();
|
||||
await load();
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
await load(true);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does nothing at all when the attempt has no bundle", async () => {
|
||||
const { load, status } = setup(null);
|
||||
await load();
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
expect(status.value).toBe("idle");
|
||||
});
|
||||
|
||||
it("reports truncation from either the record or a truncation event", async () => {
|
||||
const fromRecord = setup("bundle.ndjson", true);
|
||||
await fromRecord.load();
|
||||
expect(fromRecord.truncated.value).toBe(true);
|
||||
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
text: async () => '{"ts":1,"kind":"truncation","step_id":"s1"}',
|
||||
})) as any;
|
||||
const fromEvent = setup("other.ndjson", false);
|
||||
await fromEvent.load();
|
||||
expect(fromEvent.truncated.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
// 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/>.
|
||||
//
|
||||
// The evidence bundle for one ATTEMPT, fetched once and shared.
|
||||
//
|
||||
// It lives here rather than inside a component because two surfaces need the
|
||||
// same bytes: the per-step "Page activity" block in the step expansion, and the
|
||||
// run-level Evidence tab. Fetching in whichever mounted first would cost a
|
||||
// second 256 KB round-trip for the other, and would let the two disagree.
|
||||
//
|
||||
// The raw `fetch` is deliberate. These are presigned S3/MinIO URLs; the
|
||||
// `syntheticsService` axios wrapper attaches org auth headers, which a presigned
|
||||
// URL rejects. That service therefore exposes URL builders only, and no body
|
||||
// fetch exists to call.
|
||||
|
||||
import { computed, ref, watch, type Ref } from "vue";
|
||||
|
||||
import {
|
||||
indexEvidenceByStep,
|
||||
parseEvidenceNdjson,
|
||||
type EvidenceEvent,
|
||||
} from "@/composables/synthetics/syntheticResultsSchema";
|
||||
|
||||
export type EvidenceStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export function useSyntheticEvidence(
|
||||
/** Object-storage key of the SELECTED attempt's bundle. Null when none exists. */
|
||||
evidenceKey: Ref<string | null>,
|
||||
/** Resolves a key to a fetchable URL. Already presigned for every attempt. */
|
||||
resolveUrl: (key: string) => string,
|
||||
/** step_id -> definition, from the run's own snapshot. */
|
||||
stepDefs: Ref<Map<string, { name: string; selector: string | null }>>,
|
||||
/** `evidence_truncated` from the record. */
|
||||
recordTruncated: Ref<boolean>,
|
||||
) {
|
||||
const status = ref<EvidenceStatus>("idle");
|
||||
const events = ref<EvidenceEvent[]>([]);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch on demand, not with the record.
|
||||
*
|
||||
* Idempotent: returns immediately while a fetch is in flight and once one has
|
||||
* settled, so the two independent triggers (a step expanding, the tab opening)
|
||||
* are safe to fire in either order. `force` is the Retry path, and the only
|
||||
* way past a settled error.
|
||||
*/
|
||||
async function load(force = false): Promise<void> {
|
||||
if (status.value === "loading") return;
|
||||
if (!force && (status.value === "ready" || status.value === "error")) return;
|
||||
const key = evidenceKey.value;
|
||||
if (!key) return;
|
||||
|
||||
status.value = "loading";
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await fetch(resolveUrl(key));
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
// Named here, once, so every consumer sees the same label. An unresolved
|
||||
// id renders as the id — never blank, and never guessed from the check's
|
||||
// current config, which would relabel history after an edit.
|
||||
events.value = parseEvidenceNdjson(await res.text()).map((e) => ({
|
||||
...e,
|
||||
stepName: e.stepId ? stepDefs.value.get(e.stepId)?.name || e.stepId : null,
|
||||
}));
|
||||
status.value = "ready";
|
||||
} 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.
|
||||
error.value = e?.message ?? String(e);
|
||||
events.value = [];
|
||||
status.value = "error";
|
||||
}
|
||||
}
|
||||
|
||||
// Switching attempts changes the key. Reset rather than refetch: whether the
|
||||
// new bundle is wanted depends on what is on screen, which the caller knows
|
||||
// and this does not.
|
||||
watch(evidenceKey, () => {
|
||||
status.value = "idle";
|
||||
events.value = [];
|
||||
error.value = null;
|
||||
});
|
||||
|
||||
const index = computed(() => indexEvidenceByStep(events.value));
|
||||
|
||||
return {
|
||||
status,
|
||||
events,
|
||||
eventsByStep: computed(() => index.value.byStep),
|
||||
unattributedCount: computed(() => index.value.unattributed.length),
|
||||
truncated: computed(
|
||||
() => recordTruncated.value || events.value.some((e) => e.kind === "truncation"),
|
||||
),
|
||||
error,
|
||||
load,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import useSyntheticsRecorder from "./useSyntheticsRecorder";
|
||||
|
|
@ -137,12 +150,10 @@ describe("useSyntheticsRecorder", () => {
|
|||
expect(cmd.testIdAttr).toBe("data-test");
|
||||
|
||||
// Stream steps
|
||||
const browserSteps: WireStep[] = [
|
||||
{ id: "s1", action: "click", selector: "#go", selector_type: "css" },
|
||||
];
|
||||
const browserSteps: WireStep[] = [{ id: "s1", action: "click", selector: "#go" }];
|
||||
emitStreamEvent({ method: "setActions", browserSteps });
|
||||
expect(r.liveSteps.value).toHaveLength(1);
|
||||
expect(r.liveSteps.value[0].selectorType).toBe("CSS");
|
||||
expect(r.liveSteps.value[0].selector).toBe("#go");
|
||||
|
||||
emitStreamEvent({ method: "recordingStarted", tabId: 9, url: "https://app.test/next" });
|
||||
expect(r.currentUrl.value).toBe("https://app.test/next");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { reactive, ref } from "vue";
|
||||
import { synthetics } from "@/constants/config";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { StepAction } from "@/types/synthetics";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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, StepAction, SyntheticCheckType } from "@/types/synthetics";
|
||||
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
|
||||
|
|
@ -38,10 +51,20 @@ export const ACTION_ICONS: Record<StepAction, IconName> = {
|
|||
};
|
||||
|
||||
// ── Action groups ────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Actions that act on an element and so must name one.
|
||||
*
|
||||
* Mirrors the server's `V2_ELEMENT_ACTIONS` (`synthetics.rs`). `press` belongs
|
||||
* here for that reason: the server requires a locator for it, and while a
|
||||
* bundle-less journey could still fall back to the version-1 payload shape the
|
||||
* disagreement only cost a silent downgrade. With version 1 gone it would be a
|
||||
* 400 at save time instead.
|
||||
*/
|
||||
export const SELECTOR_ACTIONS: readonly StepAction[] = [
|
||||
"click",
|
||||
"type",
|
||||
"select",
|
||||
"press",
|
||||
"check",
|
||||
"uncheck",
|
||||
"upload",
|
||||
|
|
@ -134,9 +157,8 @@ export const actionOptions = (Object.keys(ACTION_LABELS) as StepAction[])
|
|||
}));
|
||||
|
||||
// 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.
|
||||
// v1 authoring path: a step names its element with a locator bundle, whose value
|
||||
// carries its own engine prefix.
|
||||
|
||||
// ── Value field labels (action-specific) ─────────────────────────────────
|
||||
export const VALUE_LABELS: Record<string, string> = {
|
||||
|
|
@ -239,3 +261,10 @@ export const VALUE_TOOLTIP_MAP: Record<string, string> = {
|
|||
* all, and every step silently degraded to role/text/css.
|
||||
*/
|
||||
export const DEFAULT_TEST_ID_ATTR = "data-test";
|
||||
|
||||
/**
|
||||
* How many evidence events a step expansion shows before deferring to the
|
||||
* Evidence tab. A live bundle held 158 events across two steps — uncapped, one
|
||||
* expansion would run to 136 rows and break the step timeline's scroll.
|
||||
*/
|
||||
export const INLINE_EVIDENCE_LIMIT = 5;
|
||||
|
|
|
|||
|
|
@ -9782,20 +9782,43 @@
|
|||
},
|
||||
"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",
|
||||
"locatorHelp": "Every way to find this element, tried top to bottom. Drag to reorder, add your own, or delete the ones you do not want.",
|
||||
"locatorOverrideLabel": "Add your own locator",
|
||||
"locatorOverrideApply": "Add to list",
|
||||
"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\"]",
|
||||
"locatorOrderHelp": "Tried in order, top first. The first one that matches is used.",
|
||||
"locatorOrigin": {
|
||||
"recorded": "Recorded",
|
||||
"authored": "Yours",
|
||||
"composite": "Combined"
|
||||
},
|
||||
"locatorPositionalWarning": "by position",
|
||||
"locatorPositionalWarningHelp": "Finds the element by counting siblings, so it breaks when the page reorders. Combine it with another locator, or write one of your own.",
|
||||
"locatorGeneratedIdWarning": "generated id",
|
||||
"locatorGeneratedIdWarningHelp": "This id is minted by the component library on every render, so it changes on the next deploy.",
|
||||
"locatorDelete": "Delete",
|
||||
"locatorDeleteLast": "The step needs at least one locator.",
|
||||
"locatorDuplicate": "That locator is already in the list.",
|
||||
"locatorSelectedCount": "{count} selected",
|
||||
"locatorCombine": "Combine",
|
||||
"locatorCombineTitle": "Combine locators",
|
||||
"locatorCombineApply": "Add to list",
|
||||
"locatorCombineLead": "The element must match ALL of these:",
|
||||
"locatorCombineMatchedOne": "matched 1 when recorded",
|
||||
"locatorCombineMatchedMany": "matched several when recorded",
|
||||
"locatorCombineRelationQuestion": "How do these relate?",
|
||||
"locatorCombineRelationAnd": "The same element matches both",
|
||||
"locatorCombineRelationHas": "The first one wraps the second one",
|
||||
"locatorCombineRelationHasHelp": "Use this when the text or label sits in a child element.",
|
||||
"locatorCombinePosition": "Also require it to be at position {position}",
|
||||
"locatorCombinePositionHelp": "Fails the step if the match at that position is no longer this element. Use it to catch a list that has been reordered.",
|
||||
"locatorCombineWarning": "Combining makes this step stricter, not more resilient — it fails if any part stops matching. Your other locators are still tried after it.",
|
||||
"locatorCombineResult": "Result",
|
||||
"locatorCombineFilterEngine": "This locator only narrows something else down, so it cannot be matched on its own. Choose “wraps” instead.",
|
||||
"locatorKind": {
|
||||
"test_attribute": "Test attribute",
|
||||
"role": "Role",
|
||||
|
|
@ -9834,12 +9857,6 @@
|
|||
"alwaysRunLabel": "Always run — run this step even after an earlier step failed",
|
||||
"optionalHelp": "If this step fails, the run records it as Skipped and carries on. Use it for things that may not appear, like a cookie banner or a one-time popup. A skipped step never fails the run.",
|
||||
"alwaysRunHelp": "If an earlier step fails, this step still runs during cleanup before the run ends. Use it for teardown, like signing out. Its result never changes the run's verdict, and it only applies to steps that come after the failed one.",
|
||||
"upgradeTitle": "This journey can be upgraded",
|
||||
"upgradeDescription": "It contains steps that cannot be replayed and hard waits that make runs slower and less reliable. Upgrading keeps the journey’s behaviour, converts each wait into a settle budget on the step before it, and lets the runner fall back through alternative locators.",
|
||||
"upgradeChangeCount": "{count} changes",
|
||||
"upgradeApply": "Upgrade journey",
|
||||
"upgradePreview": "Preview changes",
|
||||
"upgradeHide": "Hide changes",
|
||||
"zeroAssertionTitle": "This journey does not verify anything",
|
||||
"zeroAssertionDescription": "It checks that the steps can be performed, but not that the application is working. A journey with no assertion can click its way through a broken page and still pass. Add at least one — for example, that an element on the final page is visible.",
|
||||
"zeroAssertionAdd": "Add an assertion",
|
||||
|
|
@ -10231,7 +10248,10 @@
|
|||
"none": "No evidence bundle was uploaded for this attempt.",
|
||||
"loadFailed": "Could not load the evidence bundle: {error}",
|
||||
"retry": "Retry",
|
||||
"stack": "stack"
|
||||
"stack": "stack",
|
||||
"unattributedCount": "{count} events were not attributed to any step",
|
||||
"stepFilterBanner": "Showing events for step {step}",
|
||||
"clearStepFilter": "Clear"
|
||||
},
|
||||
"runDetail": {
|
||||
"evidenceSection": "Evidence",
|
||||
|
|
@ -10282,6 +10302,12 @@
|
|||
"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.",
|
||||
"locatorTriedOf": "{tried} of {total} tried",
|
||||
"locatorNoFallback": "This step has one locator and no fallback. When it stopped matching, there was nothing else to try.",
|
||||
"locatorOutcomeMatched": "matched",
|
||||
"locatorOutcomeNotFound": "not found",
|
||||
"locatorOutcomePrimary": "used directly",
|
||||
"locatorOutcomeNotTried": "not reached",
|
||||
"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",
|
||||
|
|
@ -10289,7 +10315,11 @@
|
|||
"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."
|
||||
"evidenceTruncated": "Some events were dropped during this run because the capture limit was reached. The bundle names what went.",
|
||||
"pageActivity": "Page activity",
|
||||
"pageActivityCount": "{shown} of {total}",
|
||||
"pageActivityCountTruncated": "{shown} of {total}+",
|
||||
"pageActivityViewAll": "View all {count} events for this step"
|
||||
},
|
||||
"runRowExpansion": {
|
||||
"browserHeader": "Browser",
|
||||
|
|
@ -10524,7 +10554,8 @@
|
|||
"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"
|
||||
"fixHighlightedFields": "Fix the highlighted fields before saving",
|
||||
"retiredAction": "\"{action}\" is no longer supported — remove or replace \"{step}\""
|
||||
},
|
||||
"privateLocations": {
|
||||
"searchPlaceholder": "Search locations...",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ vi.mock("./http", () => ({
|
|||
}));
|
||||
|
||||
import http from "./http";
|
||||
import store from "@/stores";
|
||||
|
||||
describe("Synthetics locations service", () => {
|
||||
let mockHttp: any;
|
||||
|
|
@ -258,3 +259,51 @@ describe("Synthetics locations service", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Artifact URLs ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// These are used directly in `<img src>` and `fetch()`, bypassing the axios
|
||||
// wrapper — so unlike every other call in this file they do not inherit
|
||||
// `baseURL: store.state.API_ENDPOINT` and must carry the origin themselves.
|
||||
describe("artifactUrl origin", () => {
|
||||
const KEY = "synthetics/default/mon-1/2026/07/30/RUN/EXEC/attempt-1-evidence.ndjson";
|
||||
|
||||
beforeEach(() => {
|
||||
store.state.API_ENDPOINT = "";
|
||||
});
|
||||
|
||||
it("prefixes the configured API endpoint, so it does not resolve against the dev server", () => {
|
||||
// The dev server on :8081 serves no /api route; without the origin the
|
||||
// request 404s and every screenshot and evidence bundle is dead locally.
|
||||
store.state.API_ENDPOINT = "http://localhost:5080";
|
||||
expect(synthetics.artifactUrl("default", KEY)).toBe(
|
||||
`http://localhost:5080/api/default/synthetics/mon-1/artifact?key=${encodeURIComponent(KEY)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("stays a bare path when the app is served from the API's own origin", () => {
|
||||
// "/" is same-origin, and "//api/..." would be protocol-relative — a
|
||||
// request to a host named "api".
|
||||
store.state.API_ENDPOINT = "/";
|
||||
expect(synthetics.artifactUrl("default", KEY)).toBe(
|
||||
`/api/default/synthetics/mon-1/artifact?key=${encodeURIComponent(KEY)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double the separator when the endpoint has a trailing slash", () => {
|
||||
store.state.API_ENDPOINT = "https://o2.example.dev/";
|
||||
expect(synthetics.artifactUrl("default", KEY)).toBe(
|
||||
`https://o2.example.dev/api/default/synthetics/mon-1/artifact?key=${encodeURIComponent(KEY)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a sub-path endpoint intact", () => {
|
||||
store.state.API_ENDPOINT = "https://host.dev/o2";
|
||||
expect(synthetics.artifactUrl("default", KEY)).toContain("https://host.dev/o2/api/default/");
|
||||
});
|
||||
|
||||
it("still appends the folder when one is given", () => {
|
||||
store.state.API_ENDPOINT = "http://localhost:5080";
|
||||
expect(synthetics.artifactUrl("default", KEY, "fold-1")).toContain("&folder=fold-1");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,42 @@
|
|||
// 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 http from "./http";
|
||||
import store from "@/stores";
|
||||
|
||||
const STREAM_NAME = "synthetics_results";
|
||||
|
||||
/**
|
||||
* The API origin, for URLs that do NOT go through the axios wrapper.
|
||||
*
|
||||
* Everything else in this file is issued by `http()`, which sets
|
||||
* `baseURL: store.state.API_ENDPOINT`. Artifact URLs are different: they are
|
||||
* handed to `<img src>` and `fetch()` directly, so they carry no baseURL and
|
||||
* resolve against whatever origin serves the page. In dev that is the Vite
|
||||
* server on :8081, which has no `/api` route and no proxy — so every screenshot
|
||||
* and every evidence bundle 404s until the origin is spelled out here.
|
||||
*
|
||||
* Returns "" when the app is served from the API's own origin (`API_ENDPOINT`
|
||||
* is "/" or unset). Prefixing "/" would yield `//api/...`, which the browser
|
||||
* reads as protocol-relative — a request to a host literally named "api".
|
||||
*/
|
||||
function apiOrigin(): string {
|
||||
const base = store.state.API_ENDPOINT;
|
||||
if (!base || base === "/") return "";
|
||||
return base.endsWith("/") ? base.slice(0, -1) : base;
|
||||
}
|
||||
|
||||
export interface ListRunsPayload {
|
||||
query: {
|
||||
sql: string;
|
||||
|
|
@ -89,7 +123,7 @@ const syntheticsService = {
|
|||
const parts = key.split("/");
|
||||
const synthetics_id = parts[2] ?? "_";
|
||||
const folderParam = folderId ? `&folder=${folderId}` : "";
|
||||
return `/api/${orgIdentifier}/synthetics/${synthetics_id}/artifact?key=${encodeURIComponent(key)}${folderParam}`;
|
||||
return `${apiOrigin()}/api/${orgIdentifier}/synthetics/${synthetics_id}/artifact?key=${encodeURIComponent(key)}${folderParam}`;
|
||||
},
|
||||
|
||||
// Batch-sign artifact download URLs. Returns { mode: "presigned" | "proxy",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
export type SelectorType = "CSS" | "XPath" | "Text" | "TestID" | "Role";
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
// ── Replay state machine ──────────────────────────────────────────────────────
|
||||
export type ReplayPhase = "idle" | "running" | "passed" | "failed" | "stopped";
|
||||
|
|
@ -54,33 +65,61 @@ export type StepAction =
|
|||
| "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.
|
||||
// ── Locator bundle ──────────────────────────────────────────────────────────
|
||||
// A step carries every way to find its element, tried in order, so a cosmetic
|
||||
// markup change no longer breaks a monitor that is otherwise healthy.
|
||||
//
|
||||
// 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.
|
||||
// The ORDER IS THE AUTHOR'S. It arrives as Playwright's, and from there they
|
||||
// drag rows, add locators of their own, delete ones they do not want and
|
||||
// combine recorded ones into something stricter. That replaced pinning, which
|
||||
// was exclusive: the only way to say "prefer this one" was to turn fallback off
|
||||
// entirely.
|
||||
//
|
||||
// Ordering is not cosmetic. Candidates only agree while the markup is
|
||||
// unchanged; once it changes — the case fallback exists for — a later candidate
|
||||
// may match a *different* element.
|
||||
|
||||
/** Ordered most stable to least. `css`/`xpath` are structural and brittle. */
|
||||
/** How the element is found. Says nothing about how long it will keep working. */
|
||||
export type LocatorKind = "test_attribute" | "role" | "text" | "css" | "xpath";
|
||||
|
||||
/**
|
||||
* Where a candidate came from.
|
||||
*
|
||||
* The heal-suppression signal (H1-H6): healing may replace the value of a
|
||||
* `recorded` entry in place, and must not touch anything else.
|
||||
*/
|
||||
export type LocatorOrigin = "recorded" | "authored" | "composite";
|
||||
|
||||
/** How one part of a combined locator attaches to the part before it. */
|
||||
export type CompositeRelation = "and" | "has" | "has_not" | "descendant";
|
||||
|
||||
export interface CompositePart {
|
||||
value: string;
|
||||
/** Absent on the first (base) part. */
|
||||
relation?: CompositeRelation;
|
||||
}
|
||||
|
||||
export interface LocatorCandidate {
|
||||
kind: LocatorKind;
|
||||
value: string;
|
||||
/** Absent means `recorded` — the shape every pre-Phase-2b bundle has. */
|
||||
origin?: LocatorOrigin;
|
||||
/**
|
||||
* What a combined locator was built from, and how. Present only on a
|
||||
* `composite`. Structured rather than `string[]` because the relation cannot
|
||||
* be recomputed: it depends on DOM structure the editor never sees.
|
||||
*/
|
||||
from?: CompositePart[];
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
/** Tried in order, top first. The first one that matches is used. */
|
||||
candidates: LocatorCandidate[];
|
||||
/** Author-pinned. When set, used exclusively — never falls back. */
|
||||
user_override?: LocatorCandidate | null;
|
||||
/**
|
||||
* A human has reordered, added, deleted or combined. Healing must never
|
||||
* reorder such a list.
|
||||
*/
|
||||
author_ordered?: boolean;
|
||||
}
|
||||
|
||||
// ── Version-2 settle block ──────────────────────────────────────────────────
|
||||
|
|
@ -144,7 +183,6 @@ export interface BrowserStep {
|
|||
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. */
|
||||
|
|
@ -157,7 +195,6 @@ export interface BrowserStep {
|
|||
alwaysRun?: boolean;
|
||||
value?: string;
|
||||
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
|
||||
// manually-added steps.
|
||||
|
|
@ -178,7 +215,6 @@ export interface WireStep {
|
|||
id: string;
|
||||
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.
|
||||
|
|
@ -286,11 +322,16 @@ 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. */
|
||||
/**
|
||||
* What the preview could not evaluate for this step (X-8.2).
|
||||
*
|
||||
* Declared because the extension really sends it — `background.ts` sets it
|
||||
* from `replayFidelity`, and `useSyntheticsRecorder` reads it straight
|
||||
* into {@link StepReplayResult}. Omitting it here made that read a type
|
||||
* error while the value flowed anyway: a green result WITH notes is a
|
||||
* weaker claim than one without, and this is the field carrying the
|
||||
* difference.
|
||||
*/
|
||||
fidelity?: { level: string; notes: string[] };
|
||||
}
|
||||
| { method: "stepReplayStarted"; stepId: string; stepName?: string };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
// 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 { BrowserCheck, BrowserStep } from "@/types/synthetics";
|
||||
import { buildCreateBrowserTestPayload } from "./buildPayload";
|
||||
|
||||
function journey(): BrowserStep[] {
|
||||
return [
|
||||
{ id: "s1", action: "navigate", name: "Open", value: "https://app.test" },
|
||||
{
|
||||
id: "s2",
|
||||
action: "click",
|
||||
name: "Sign in",
|
||||
locator: { candidates: [{ kind: "test_attribute", value: '[data-test="login-sign-in"]' }] },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function check(overrides: Partial<BrowserCheck> = {}): BrowserCheck {
|
||||
return {
|
||||
name: "Cloud login",
|
||||
url: "https://app.test",
|
||||
enabled: true,
|
||||
tags: [],
|
||||
journey: journey(),
|
||||
schedule: { type: "interval", intervalValue: 5, intervalUnit: "minutes" },
|
||||
locations: ["us-east"],
|
||||
notifications: { destinations: [] },
|
||||
rum: { collect: true, sessionReplay: false },
|
||||
capture: { screenshot: "on-fail", trace: "on-fail" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildCreateBrowserTestPayload", () => {
|
||||
// `steps_version` existed to say which of two step formats the array was in.
|
||||
// There is only one format now, so sending the key at all would be rejected
|
||||
// as an unknown field.
|
||||
it("sends no steps_version key", () => {
|
||||
const config = buildCreateBrowserTestPayload(check()).config as Record<string, unknown>;
|
||||
|
||||
expect("steps_version" in config).toBe(false);
|
||||
});
|
||||
|
||||
it("builds every step through the version-2 writer", () => {
|
||||
const config = buildCreateBrowserTestPayload(check()).config as Record<string, unknown>;
|
||||
const steps = config.steps as Record<string, unknown>[];
|
||||
|
||||
expect(steps.map((s) => s.action)).toEqual(["navigate", "click"]);
|
||||
expect(steps[1].locator).toEqual({
|
||||
candidates: [{ kind: "test_attribute", value: '[data-test="login-sign-in"]' }],
|
||||
});
|
||||
// The version-1 fields the editor used to carry never reach the wire.
|
||||
expect(steps[1]).not.toHaveProperty("selector");
|
||||
expect(steps[1]).not.toHaveProperty("selector_type");
|
||||
expect(steps[1]).not.toHaveProperty("code");
|
||||
});
|
||||
|
||||
// The save gate rejects these before the payload is built. The throw is the
|
||||
// second line of defence, so that a gate that ever stops agreeing with the
|
||||
// writer fails loudly here instead of posting a step the server refuses.
|
||||
it("throws rather than emitting a step it cannot build", () => {
|
||||
const withRetired = check({
|
||||
journey: [...journey(), { id: "s3", action: "hover", name: "Reveal" }],
|
||||
});
|
||||
|
||||
expect(() => buildCreateBrowserTestPayload(withRetired)).toThrow(/cannot be stored/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { DateTime } from "luxon";
|
||||
import type {
|
||||
BrowserCheck,
|
||||
|
|
@ -12,8 +25,8 @@ import type {
|
|||
TlsCheckConfig,
|
||||
} from "@/types/synthetics";
|
||||
import { convertDateToTimestamp } from "@/utils/timezone";
|
||||
import { journeyToWireSteps, mapWireSteps } from "./mapRecordedStep";
|
||||
import { buildV2Steps, isV2Journey } from "./buildV2Steps";
|
||||
import { mapWireSteps } from "./mapRecordedStep";
|
||||
import { buildV2Steps } from "./buildV2Steps";
|
||||
import { useLocalTimezone } from "../storage";
|
||||
|
||||
// ── Outbound: BrowserCheck → API payload ─────────────────────────────────────
|
||||
|
|
@ -125,12 +138,11 @@ export function buildCreateBrowserTestPayload(check: BrowserCheck): Record<strin
|
|||
frequency: buildFrequency(schedule),
|
||||
|
||||
config: {
|
||||
// `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) }),
|
||||
// One step format, so no version key: the server refuses unknown fields,
|
||||
// and `steps_version` is now one of them. Steps are built field by field
|
||||
// rather than spread, for the same reason. A journey that cannot be built
|
||||
// throws here — the save gate is what stops it getting this far.
|
||||
steps: buildV2Steps(journey),
|
||||
browser_devices: browserDevices ?? [{ browser: "chromium", device: "desktop" }],
|
||||
timeout_ms: 30000,
|
||||
capture: {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
// 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 { buildV2Step, buildV2Steps, isV2Journey } from "./buildV2Steps";
|
||||
import { buildV2Step, buildV2Steps, isSaveableJourney } from "./buildV2Steps";
|
||||
|
||||
function step(overrides: Partial<BrowserStep> = {}): BrowserStep {
|
||||
return {
|
||||
|
|
@ -10,11 +23,9 @@ function step(overrides: Partial<BrowserStep> = {}): BrowserStep {
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -24,23 +35,22 @@ const nav = (): BrowserStep => ({
|
|||
action: "navigate",
|
||||
name: "Open",
|
||||
value: "https://example.com",
|
||||
code: "",
|
||||
});
|
||||
|
||||
describe("isV2Journey", () => {
|
||||
describe("isSaveableJourney", () => {
|
||||
it("accepts a journey whose every element step carries a bundle", () => {
|
||||
expect(isV2Journey([nav(), step()])).toBe(true);
|
||||
expect(isSaveableJourney([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.
|
||||
// All-or-nothing: a half-recorded journey has no honest shape. Storing it
|
||||
// would fail validation on the bundle-less steps, and there is no longer a
|
||||
// version-1 shape to fall back to.
|
||||
it("rejects a journey where one step has no bundle", () => {
|
||||
expect(isV2Journey([nav(), step(), step({ id: "s3", locator: undefined })])).toBe(false);
|
||||
expect(isSaveableJourney([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);
|
||||
expect(isSaveableJourney([nav(), step(), step({ id: "s3", action: "wait" })])).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a page-level assertion with no element at all", () => {
|
||||
|
|
@ -51,17 +61,17 @@ describe("isV2Journey", () => {
|
|||
selector: undefined,
|
||||
assertion: { kind: "url_matches", expected: "**/web/**" },
|
||||
});
|
||||
expect(isV2Journey([nav(), urlAssert])).toBe(true);
|
||||
expect(isSaveableJourney([nav(), urlAssert])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty journey", () => {
|
||||
expect(isV2Journey([])).toBe(false);
|
||||
expect(isSaveableJourney([])).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.
|
||||
// The server validates steps with deny_unknown_fields, so a stray `code` or
|
||||
// `selectorType` would be 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"]);
|
||||
|
|
@ -80,16 +90,46 @@ describe("buildV2Step", () => {
|
|||
expect(buildV2Step(step({ action: "type", value: "omkar" })).value).toBe("omkar");
|
||||
});
|
||||
|
||||
it("carries a pin, which the runner uses exclusively", () => {
|
||||
// Miss this and the editor's work never reaches storage: the save looks fine,
|
||||
// and healing later overwrites an authored entry because nothing recorded
|
||||
// that a human wrote it.
|
||||
it("carries provenance to the wire, and no pin field", () => {
|
||||
const wire = buildV2Step(
|
||||
step({
|
||||
locator: {
|
||||
candidates: [{ kind: "test_attribute", value: "#a" }],
|
||||
user_override: { kind: "css", value: "#pinned" },
|
||||
candidates: [
|
||||
{ kind: "css", value: "#authored", origin: "authored" },
|
||||
{
|
||||
kind: "test_attribute",
|
||||
value: '[data-test="row"] >> internal:and="#b"',
|
||||
origin: "composite",
|
||||
from: [{ value: '[data-test="row"]' }, { relation: "and", value: "#b" }],
|
||||
},
|
||||
{ kind: "test_attribute", value: "#a" },
|
||||
],
|
||||
author_ordered: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(wire.locator?.user_override).toEqual({ kind: "css", value: "#pinned" });
|
||||
|
||||
expect(wire.locator?.candidates[0]).toEqual({
|
||||
kind: "css",
|
||||
value: "#authored",
|
||||
origin: "authored",
|
||||
});
|
||||
expect(wire.locator?.candidates[1].from).toEqual([
|
||||
{ value: '[data-test="row"]' },
|
||||
{ relation: "and", value: "#b" },
|
||||
]);
|
||||
// Absent, not "recorded": a bundle that predates provenance has to keep
|
||||
// round-tripping unchanged while the deploy catches up.
|
||||
expect(wire.locator?.candidates[2]).toEqual({ kind: "test_attribute", value: "#a" });
|
||||
expect(wire.locator?.author_ordered).toBe(true);
|
||||
expect(wire.locator).not.toHaveProperty("user_override");
|
||||
});
|
||||
|
||||
it("omits author_ordered when nobody has touched the list", () => {
|
||||
expect(buildV2Step(step()).locator).not.toHaveProperty("author_ordered");
|
||||
});
|
||||
|
||||
it("carries the settle block and defaults a signal to advisory", () => {
|
||||
|
|
@ -146,8 +186,8 @@ describe("buildV2Step", () => {
|
|||
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("refuses to build a step that cannot be stored", () => {
|
||||
expect(() => buildV2Step(step({ action: "hover" }))).toThrow(/cannot be stored/);
|
||||
});
|
||||
|
||||
it("maps a whole journey in order", () => {
|
||||
|
|
@ -155,22 +195,24 @@ describe("buildV2Step", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
// SE-18 regression. The gate is all-or-nothing, so one hand-added step without a
|
||||
// locator used to flip an entire recorded journey to version 1, sending every
|
||||
// other step's bundle, settle and assertion down the untyped path. It now blocks
|
||||
// the save instead — and a seeded bundle plus an author-supplied pin still passes.
|
||||
describe("SE-18: a completed manual step does not block the save", () => {
|
||||
it("stays saveable 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: "s1", action: "navigate", name: "Open app", value: "https://example.com" },
|
||||
{
|
||||
id: "s2",
|
||||
action: "click",
|
||||
name: "Sign in",
|
||||
code: "",
|
||||
locator: { candidates: [], user_override: { kind: "css", value: "#sign-in" } },
|
||||
locator: {
|
||||
candidates: [{ kind: "css", value: "#sign-in", origin: "authored" }],
|
||||
author_ordered: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(isV2Journey(journey)).toBe(true);
|
||||
expect(isSaveableJourney(journey)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,26 @@
|
|||
// 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 { BrowserStep, StepAction } from "@/types/synthetics";
|
||||
import { RETIRED_ACTIONS } from "@/constants/synthetics";
|
||||
import { stepIsMissingTarget } from "./stepTarget";
|
||||
|
||||
/**
|
||||
* The version-2 step as it goes on the wire.
|
||||
* The step as it goes on the wire.
|
||||
*
|
||||
* Deliberately a separate type from {@link BrowserStep}: the server validates v2
|
||||
* Deliberately a separate type from {@link BrowserStep}: the server validates
|
||||
* 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
|
||||
|
|
@ -18,8 +32,13 @@ export interface V2WireStep {
|
|||
name?: string;
|
||||
url?: string;
|
||||
locator?: {
|
||||
candidates: Array<{ kind: string; value: string }>;
|
||||
user_override?: { kind: string; value: string } | null;
|
||||
candidates: Array<{
|
||||
kind: string;
|
||||
value: string;
|
||||
origin?: string;
|
||||
from?: Array<{ value: string; relation?: string }>;
|
||||
}>;
|
||||
author_ordered?: boolean;
|
||||
};
|
||||
value?: string;
|
||||
key?: string;
|
||||
|
|
@ -49,28 +68,31 @@ const ACTION_TO_V2: Partial<Record<StepAction, string>> = {
|
|||
assert: "assert",
|
||||
};
|
||||
|
||||
/** Actions that carry no element and therefore need no locator. */
|
||||
const PAGE_LEVEL_ASSERTIONS = new Set(["url_matches", "page_title"]);
|
||||
/**
|
||||
* Whether this action can be stored at all.
|
||||
*
|
||||
* A retired action has no runner behind it, and an action outside
|
||||
* {@link ACTION_TO_V2} has no name the server recognises. Either way the step
|
||||
* cannot be built, so the save gate has to say so rather than let
|
||||
* {@link buildV2Step} throw at payload time.
|
||||
*/
|
||||
export function isStorableAction(action: string): boolean {
|
||||
if ((RETIRED_ACTIONS as readonly string[]).includes(action)) return false;
|
||||
return !!ACTION_TO_V2[action as StepAction];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a journey can be stored as version 2.
|
||||
* Whether every step of this journey can be stored.
|
||||
*
|
||||
* 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.
|
||||
* All-or-nothing on purpose, and it is now the save gate rather than a version
|
||||
* question: a journey that fails this used to fall back to the version-1 payload
|
||||
* shape, which silently discarded every locator bundle the recorder captured.
|
||||
* With version 1 gone there is nothing to fall back to, so the answer has to
|
||||
* reach the author instead — see `makeBrowserCheckSaveSchema`.
|
||||
*/
|
||||
export function isV2Journey(steps: BrowserStep[]): boolean {
|
||||
export function isSaveableJourney(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);
|
||||
});
|
||||
return steps.every((step) => isStorableAction(step.action) && !stepIsMissingTarget(step));
|
||||
}
|
||||
|
||||
/** The value field a v2 step uses for this action. The UI keeps only one. */
|
||||
|
|
@ -93,18 +115,18 @@ function v2Value(step: BrowserStep): Pick<V2WireStep, "url" | "value" | "key" |
|
|||
}
|
||||
|
||||
/**
|
||||
* Build the stored v2 step for one editor step.
|
||||
* Build the stored 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];
|
||||
const action = isStorableAction(step.action) ? ACTION_TO_V2[step.action] : undefined;
|
||||
if (!action) {
|
||||
throw new Error(
|
||||
`step "${step.id}": "${step.action}" has no version-2 equivalent. ` +
|
||||
`Check isV2Journey before building a version-2 payload.`,
|
||||
`step "${step.id}": "${step.action}" cannot be stored. ` +
|
||||
`Check isSaveableJourney before building a payload.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -113,15 +135,23 @@ export function buildV2Step(step: BrowserStep): V2WireStep {
|
|||
if (step.name) wire.name = step.name;
|
||||
Object.assign(wire, v2Value(step));
|
||||
|
||||
if (step.locator?.candidates?.length || step.locator?.user_override) {
|
||||
if (step.locator?.candidates?.length) {
|
||||
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,
|
||||
},
|
||||
}),
|
||||
// Provenance travels. Without it the editor's work never reaches storage:
|
||||
// the save looks fine, and healing later overwrites an authored entry
|
||||
// because nothing recorded that a human wrote it.
|
||||
candidates: step.locator.candidates.map((c) => ({
|
||||
kind: c.kind,
|
||||
value: c.value,
|
||||
...(c.origin && { origin: c.origin }),
|
||||
...(c.from?.length && {
|
||||
from: c.from.map((p) => ({
|
||||
value: p.value,
|
||||
...(p.relation && { relation: p.relation }),
|
||||
})),
|
||||
}),
|
||||
})),
|
||||
...(step.locator.author_ordered && { author_ordered: true }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
// 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 {
|
||||
canDefaultToAnd,
|
||||
composeLocator,
|
||||
decomposeLocator,
|
||||
describeComposite,
|
||||
isBareFilterEngine,
|
||||
isCompositeSelector,
|
||||
type CompositePart,
|
||||
} from "./composeLocator";
|
||||
|
||||
const ROW = '[data-test="org-row"]';
|
||||
const TEXT = 'internal:text="acme_prod"';
|
||||
|
||||
describe("composeLocator", () => {
|
||||
it("emits the right engine for each of the four relations", () => {
|
||||
const base = { value: ROW };
|
||||
expect(composeLocator({ parts: [base, { relation: "and", value: TEXT }] })).toBe(
|
||||
`${ROW} >> internal:and=${JSON.stringify(TEXT)}`,
|
||||
);
|
||||
expect(composeLocator({ parts: [base, { relation: "has", value: TEXT }] })).toBe(
|
||||
`${ROW} >> internal:has=${JSON.stringify(TEXT)}`,
|
||||
);
|
||||
expect(composeLocator({ parts: [base, { relation: "has_not", value: TEXT }] })).toBe(
|
||||
`${ROW} >> internal:has-not=${JSON.stringify(TEXT)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// `descendant` is `A >> B`, a plain chain. Quoting it would make Playwright
|
||||
// parse the whole selector as one opaque engine body.
|
||||
it("does not JSON-quote a descendant, which is a plain chain", () => {
|
||||
expect(
|
||||
composeLocator({ parts: [{ value: ROW }, { relation: "descendant", value: "span" }] }),
|
||||
).toBe(`${ROW} >> span`);
|
||||
});
|
||||
|
||||
it("escapes the inner selector for the three nested relations", () => {
|
||||
const built = composeLocator({
|
||||
parts: [{ value: ROW }, { relation: "and", value: '[data-org="acme"]' }],
|
||||
});
|
||||
expect(built).toContain('internal:and="[data-org=\\"acme\\"]"');
|
||||
});
|
||||
|
||||
// Order inside the string is load-bearing: parts run left to right and `nth`
|
||||
// is itself a part. `A >> nth=4 ∧ B` takes A's fifth match and requires it to
|
||||
// be B; `A ∧ B >> nth=4` intersects to one element then asks for the fifth,
|
||||
// which matches nothing. The second shape must be unexpressible.
|
||||
it("re-emits the base part's nth BEFORE the first join", () => {
|
||||
const built = composeLocator({
|
||||
parts: [{ value: `${ROW} >> nth=1` }, { relation: "and", value: TEXT }],
|
||||
requirePosition: true,
|
||||
});
|
||||
expect(built).toBe(`${ROW} >> nth=1 >> internal:and=${JSON.stringify(TEXT)}`);
|
||||
expect(built.indexOf("nth=")).toBeLessThan(built.indexOf("internal:and="));
|
||||
});
|
||||
|
||||
it("strips the base part's nth when the position is not required", () => {
|
||||
expect(
|
||||
composeLocator({
|
||||
parts: [{ value: `${ROW} >> nth=1` }, { relation: "and", value: TEXT }],
|
||||
}),
|
||||
).toBe(`${ROW} >> internal:and=${JSON.stringify(TEXT)}`);
|
||||
});
|
||||
|
||||
it("keeps an nth that is not on the base part out of the way", () => {
|
||||
// The index belongs to the inner selector, which is quoted whole.
|
||||
const built = composeLocator({
|
||||
parts: [{ value: ROW }, { relation: "and", value: `div >> nth=0` }],
|
||||
});
|
||||
expect(built).toBe(`${ROW} >> internal:and=${JSON.stringify("div >> nth=0")}`);
|
||||
});
|
||||
|
||||
it("nests a three-way combination left to right", () => {
|
||||
expect(
|
||||
composeLocator({
|
||||
parts: [
|
||||
{ value: ROW },
|
||||
{ relation: "and", value: '[data-org="acme"]' },
|
||||
{ relation: "has", value: TEXT },
|
||||
],
|
||||
}),
|
||||
).toBe(
|
||||
`${ROW} >> internal:and=${JSON.stringify('[data-org="acme"]')} >> internal:has=${JSON.stringify(TEXT)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an empty string for no parts", () => {
|
||||
expect(composeLocator({ parts: [] })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decomposeLocator", () => {
|
||||
function roundTrip(parts: CompositePart[]) {
|
||||
return decomposeLocator(composeLocator({ parts }));
|
||||
}
|
||||
|
||||
it("round-trips a two-part combination, relation included", () => {
|
||||
const parts: CompositePart[] = [{ value: ROW }, { relation: "and", value: TEXT }];
|
||||
expect(roundTrip(parts)).toEqual(parts);
|
||||
});
|
||||
|
||||
it("round-trips a three-way combination", () => {
|
||||
const parts: CompositePart[] = [
|
||||
{ value: ROW },
|
||||
{ relation: "and", value: '[data-org="acme"]' },
|
||||
{ relation: "has", value: TEXT },
|
||||
];
|
||||
expect(roundTrip(parts)).toEqual(parts);
|
||||
});
|
||||
|
||||
// "The delete button in the row containing acme_prod" — has, then descendant.
|
||||
// A single flat relation on the candidate could not express this, which is
|
||||
// why the relation lives on each join.
|
||||
it("round-trips mixed relations", () => {
|
||||
const parts: CompositePart[] = [
|
||||
{ value: ROW },
|
||||
{ relation: "has", value: TEXT },
|
||||
{ relation: "descendant", value: 'button[aria-label="Delete"]' },
|
||||
];
|
||||
expect(roundTrip(parts)).toEqual(parts);
|
||||
});
|
||||
|
||||
it("round-trips has_not", () => {
|
||||
const parts: CompositePart[] = [{ value: ROW }, { relation: "has_not", value: TEXT }];
|
||||
expect(roundTrip(parts)).toEqual(parts);
|
||||
});
|
||||
|
||||
it("keeps a multi-token base together", () => {
|
||||
const parts: CompositePart[] = [
|
||||
{ value: "div.list >> internal:role=row" },
|
||||
{ relation: "and", value: TEXT },
|
||||
];
|
||||
expect(roundTrip(parts)).toEqual(parts);
|
||||
});
|
||||
|
||||
it("returns [] for a value nothing combined", () => {
|
||||
expect(decomposeLocator(ROW)).toEqual([]);
|
||||
expect(decomposeLocator("")).toEqual([]);
|
||||
// A plain recorded chain is not a combination — nobody chose a relation.
|
||||
expect(decomposeLocator("div >> internal:has-text=/^acme$/")).toEqual([]);
|
||||
expect(isCompositeSelector(ROW)).toBe(false);
|
||||
expect(
|
||||
isCompositeSelector(
|
||||
composeLocator({ parts: [{ value: ROW }, { relation: "and", value: TEXT }] }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is not fooled by a >> inside a quoted engine body", () => {
|
||||
const inner = "div >> internal:has-text=/^acme$/";
|
||||
const parts = decomposeLocator(`${ROW} >> internal:and=${JSON.stringify(inner)}`);
|
||||
expect(parts).toEqual([{ value: ROW }, { relation: "and", value: inner }]);
|
||||
});
|
||||
|
||||
it("returns [] rather than half a result when the body is not parseable", () => {
|
||||
expect(decomposeLocator(`${ROW} >> internal:and="unterminated`)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// The defect the Task 0 spike caught, and the reason the relation is stored
|
||||
// rather than assumed. `and` intersects RESULT SETS and runs the inner selector
|
||||
// against the document root, so a bare filter engine on the right filters the
|
||||
// document, matches nothing, and the intersection is empty — with no error.
|
||||
describe("isBareFilterEngine", () => {
|
||||
it("flags every filter engine", () => {
|
||||
expect(isBareFilterEngine('internal:has-text="acme"')).toBe(true);
|
||||
expect(isBareFilterEngine('internal:has-not-text="acme"')).toBe(true);
|
||||
expect(isBareFilterEngine('internal:has="span"')).toBe(true);
|
||||
expect(isBareFilterEngine('internal:has-not="span"')).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves search engines and chains alone", () => {
|
||||
expect(isBareFilterEngine(TEXT)).toBe(false);
|
||||
expect(isBareFilterEngine(ROW)).toBe(false);
|
||||
expect(isBareFilterEngine('internal:role=button[name="Go"i]')).toBe(false);
|
||||
// A filter reached THROUGH a search engine is fine — the search engine
|
||||
// supplies the elements the filter then keeps or drops.
|
||||
expect(isBareFilterEngine("div >> internal:has-text=/^acme$/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canDefaultToAnd", () => {
|
||||
const recorded = [ROW, TEXT];
|
||||
|
||||
// Playwright verifies each candidate against the target before storing it, so
|
||||
// every recorded candidate resolves to the same element by construction, and
|
||||
// intersecting two such sets gives that element.
|
||||
it("is true when every part came from the recording", () => {
|
||||
expect(canDefaultToAnd([{ value: ROW }, { value: TEXT }], recorded)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false as soon as an author-written locator joins in", () => {
|
||||
expect(canDefaultToAnd([{ value: ROW }, { value: "#hand-written" }], recorded)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for no parts at all", () => {
|
||||
expect(canDefaultToAnd([], recorded)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeComposite", () => {
|
||||
it("reads as prose, not as escaped selector syntax", () => {
|
||||
expect(
|
||||
describeComposite([
|
||||
{ value: ROW },
|
||||
{ relation: "has", value: TEXT },
|
||||
{ relation: "descendant", value: "button" },
|
||||
]),
|
||||
).toBe(`${ROW} containing ${TEXT} inside button`);
|
||||
});
|
||||
|
||||
it("handles has_not and an empty list", () => {
|
||||
expect(describeComposite([{ value: ROW }, { relation: "has_not", value: TEXT }])).toBe(
|
||||
`${ROW} not containing ${TEXT}`,
|
||||
);
|
||||
expect(describeComposite([])).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
// 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/>.
|
||||
|
||||
/**
|
||||
* Building and reading a combined locator.
|
||||
*
|
||||
* Some elements cannot be found by any single recorded locator. The org
|
||||
* switcher is the canonical case: every row emits the same `data-test`, the
|
||||
* list is virtualised so an index means "the second RENDERED row", and it is
|
||||
* filterable so search changes which rows are in the set. Every candidate is
|
||||
* positional, and re-ranking positional candidates cannot help.
|
||||
*
|
||||
* The intersection of two ambiguous locators, though, is unique — and carries
|
||||
* no index at all. That is what this builds.
|
||||
*
|
||||
* The strings it emits run natively: `page.locator()` parses `internal:and=`
|
||||
* and friends, so nothing downstream needs a builder. The probe, the extension
|
||||
* player and results display all keep handing `value` to `page.locator()`.
|
||||
*
|
||||
* **Combining makes a step stricter, not more resilient.** `A ∧ B` breaks if
|
||||
* either A or B breaks. What it buys is precision, and — when the index is kept
|
||||
* — drift detection. Resilience stays with the ordered list of candidates.
|
||||
*/
|
||||
|
||||
export type CompositeRelation = "and" | "has" | "has_not" | "descendant";
|
||||
|
||||
/** A part of a combined locator. `relation` is absent on the first (base) part. */
|
||||
export interface CompositePart {
|
||||
value: string;
|
||||
relation?: CompositeRelation;
|
||||
}
|
||||
|
||||
export interface ComposeInput {
|
||||
parts: CompositePart[];
|
||||
/**
|
||||
* Keep the base part's `nth=` index. Off by default: an index is what
|
||||
* combining exists to remove. On, it turns the locator into an assertion —
|
||||
* the step fails loudly the day the Nth match stops being this element,
|
||||
* instead of acting on the wrong one.
|
||||
*/
|
||||
requirePosition?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relation → the engine it serialises to.
|
||||
*
|
||||
* Named after Playwright's own operations rather than CSS's, because the stored
|
||||
* value IS a Playwright selector string and anyone debugging a monitor reads
|
||||
* Playwright's docs. `descendant` is the odd one out and is not JSON-quoted: it
|
||||
* is a plain chain, `A >> B`, and it acts on B rather than narrowing A.
|
||||
*/
|
||||
const RELATION_ENGINE: Record<Exclude<CompositeRelation, "descendant">, string> = {
|
||||
and: "internal:and",
|
||||
has: "internal:has",
|
||||
has_not: "internal:has-not",
|
||||
};
|
||||
|
||||
/** A trailing positional token on the base part, e.g. `>> nth=1`. */
|
||||
const TRAILING_NTH = /\s*>>\s*nth=(\d+)\s*$/;
|
||||
|
||||
/**
|
||||
* Engines that keep or drop the element they are handed, rather than searching.
|
||||
*
|
||||
* This distinction is not academic — it is the one the Task 0 spike caught.
|
||||
* `and` intersects RESULT SETS and runs the inner selector against the document
|
||||
* root, so a bare filter engine on the right filters the document, matches
|
||||
* nothing, and the intersection is empty. Silently: there is no error, the step
|
||||
* just never resolves.
|
||||
*
|
||||
* A recorded candidate is never a bare filter engine — Playwright's generator
|
||||
* always emits a search engine first — so this only catches locators an author
|
||||
* typed. `has` expresses the same intent correctly.
|
||||
*/
|
||||
const BARE_FILTER_ENGINE = /^\s*internal:(?:has-text|has-not-text|has|has-not)=/;
|
||||
|
||||
/** Would this value match nothing if used as the right-hand side of `and`? */
|
||||
export function isBareFilterEngine(value: string): boolean {
|
||||
return BARE_FILTER_ENGINE.test(value);
|
||||
}
|
||||
|
||||
function serializeJoin(part: CompositePart): string {
|
||||
const relation = part.relation ?? "and";
|
||||
if (relation === "descendant") return ` >> ${part.value}`;
|
||||
return ` >> ${RELATION_ENGINE[relation]}=${JSON.stringify(part.value)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the selector string for a combined locator.
|
||||
*
|
||||
* The base part's `nth=` is re-emitted BEFORE the first join, always. Order
|
||||
* inside the string is load-bearing because parts run left to right and `nth`
|
||||
* is itself a part: `A >> nth=4 ∧ B` takes A's fifth match and requires it to
|
||||
* be B, while `A ∧ B >> nth=4` intersects down to one element and then asks for
|
||||
* the fifth, which matches nothing. That second shape is unexpressible here by
|
||||
* construction, which is why it needs no error message.
|
||||
*/
|
||||
export function composeLocator(input: ComposeInput): string {
|
||||
const [base, ...joins] = input.parts;
|
||||
if (!base) return "";
|
||||
|
||||
const nth = base.value.match(TRAILING_NTH);
|
||||
const head = nth ? base.value.slice(0, nth.index) : base.value;
|
||||
const position = nth && input.requirePosition ? ` >> nth=${nth[1]}` : "";
|
||||
|
||||
return head.trim() + position + joins.map(serializeJoin).join("");
|
||||
}
|
||||
|
||||
/** Split on `>>` at the top level only — a quoted engine body is opaque. */
|
||||
function topLevelParts(value: string): string[] {
|
||||
const out: string[] = [];
|
||||
let current = "";
|
||||
let inString = false;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const ch = value[i];
|
||||
if (inString) {
|
||||
current += ch;
|
||||
if (ch === "\\") {
|
||||
current += value[++i] ?? "";
|
||||
} else if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ">" && value[i + 1] === ">") {
|
||||
out.push(current);
|
||||
current = "";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
out.push(current);
|
||||
return out.map((p) => p.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const JOIN_TOKEN = /^internal:(and|has-not|has)=(.*)$/s;
|
||||
const ENGINE_RELATION: Record<string, CompositeRelation> = {
|
||||
and: "and",
|
||||
has: "has",
|
||||
"has-not": "has_not",
|
||||
};
|
||||
|
||||
/**
|
||||
* Recover the parts AND relations of a combined locator. Inverse of
|
||||
* {@link composeLocator}; `[]` when the value is not combined.
|
||||
*
|
||||
* Reading it back matters for more than display: `internal:and=` is an internal
|
||||
* engine with no public compatibility promise (R2b.f), so if upstream renames
|
||||
* one, stored values can be rewritten mechanically rather than by hand.
|
||||
*/
|
||||
export function decomposeLocator(value: string): CompositePart[] {
|
||||
const tokens = topLevelParts(value);
|
||||
if (tokens.length < 2) return [];
|
||||
|
||||
const parts: CompositePart[] = [];
|
||||
let base = "";
|
||||
for (const token of tokens) {
|
||||
const join = token.match(JOIN_TOKEN);
|
||||
if (join) {
|
||||
let inner: string;
|
||||
try {
|
||||
inner = JSON.parse(join[2]) as string;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
parts.push({ relation: ENGINE_RELATION[join[1]], value: inner });
|
||||
continue;
|
||||
}
|
||||
// Not a join. Before the first one it is still the base; after one it is a
|
||||
// `descendant` chain step.
|
||||
if (parts.length) parts.push({ relation: "descendant", value: token });
|
||||
else base = base ? `${base} >> ${token}` : token;
|
||||
}
|
||||
|
||||
if (!parts.length) return [];
|
||||
return [{ value: base }, ...parts];
|
||||
}
|
||||
|
||||
export function isCompositeSelector(value: string): boolean {
|
||||
return decomposeLocator(value).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every part came from the recording, so `and` is provably safe.
|
||||
*
|
||||
* Playwright verifies each candidate against the target before storing it —
|
||||
* `chooseFirstSelector` returns only when `result[0] === targetElement &&
|
||||
* result.length === 1` — so every recorded candidate resolves to the same
|
||||
* element by construction, and intersecting two sets that both contain exactly
|
||||
* the target gives the target.
|
||||
*
|
||||
* The guarantee is gone the moment an author-written locator joins the mix,
|
||||
* which is when the editor has to ask which relation they mean.
|
||||
*/
|
||||
export function canDefaultToAnd(parts: CompositePart[], recorded: string[]): boolean {
|
||||
const known = new Set(recorded);
|
||||
return parts.length > 0 && parts.every((p) => known.has(p.value));
|
||||
}
|
||||
|
||||
/** Relation → the phrase used to describe a join in the compact row. */
|
||||
const RELATION_PHRASE: Record<CompositeRelation, string> = {
|
||||
and: "and",
|
||||
has: "containing",
|
||||
has_not: "not containing",
|
||||
descendant: "inside",
|
||||
};
|
||||
|
||||
/**
|
||||
* A one-line, readable summary of a combined locator, for the compact step row.
|
||||
*
|
||||
* Prose rather than the selector string. The string is JSON-escaped Playwright
|
||||
* syntax and unreadable at a glance, which is also why the dialog shows it as a
|
||||
* copy target rather than as something to edit.
|
||||
*/
|
||||
export function describeComposite(parts: CompositePart[]): string {
|
||||
const [base, ...joins] = parts;
|
||||
if (!base) return "";
|
||||
return joins.reduce(
|
||||
(acc, p) => `${acc} ${RELATION_PHRASE[p.relation ?? "and"]} ${p.value}`,
|
||||
base.value,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { deriveLocatorKind } from "./deriveLocatorKind";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
// 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 { LocatorKind } from "@/types/synthetics";
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue