+
+
diff --git a/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.spec.ts b/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.spec.ts
deleted file mode 100644
index ea4014644e..0000000000
--- a/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.spec.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright 2026 OpenObserve Inc.
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-import { describe, expect, it } from "vitest";
-import { mount } from "@vue/test-utils";
-import { createI18n } from "vue-i18n";
-import TestIdMisconfiguredNotice from "./TestIdMisconfiguredNotice.vue";
-import type { BrowserStep } from "@/types/synthetics";
-import en from "@/locales/languages/en-US.json";
-
-const i18n = createI18n({ legacy: false, locale: "en-US", messages: { "en-US": en } });
-
-const NOTICE = '[data-test="synthetics-journey-testid-misconfigured"]';
-
-function step(kind: string | null): BrowserStep {
- return {
- id: "s1",
- action: "click",
- name: "Click",
- locator: kind ? { candidates: [{ kind, value: "x" }] } : undefined,
- } as unknown as BrowserStep;
-}
-
-function render(steps: BrowserStep[]) {
- return mount(TestIdMisconfiguredNotice, {
- props: { steps, testIdAttr: "data-testid" },
- global: { plugins: [i18n] },
- });
-}
-
-describe("TestIdMisconfiguredNotice", () => {
- it("warns when no step in the recording produced a test_attribute candidate", () => {
- // The silent failure: upstream's generator emits nothing at test-id rank
- // when the configured attribute is not the one the app uses, and every step
- // degrades to role/text/css without an error anywhere.
- expect(
- render([step("role"), step("css")])
- .find(NOTICE)
- .exists(),
- ).toBe(true);
- });
-
- it("names the attribute that was actually used, so the fix is obvious", () => {
- expect(render([step("css")]).text()).toContain("data-testid");
- });
-
- it("stays silent when any step found a test attribute", () => {
- expect(
- render([step("css"), step("test_attribute")])
- .find(NOTICE)
- .exists(),
- ).toBe(false);
- });
-
- it("stays silent for a journey with no element steps at all", () => {
- // A navigate-only journey has nothing to find; zero test attributes there
- // is not evidence of anything.
- expect(
- render([step(null)])
- .find(NOTICE)
- .exists(),
- ).toBe(false);
- expect(render([]).find(NOTICE).exists()).toBe(false);
- });
-
- it("can be dismissed — a page may genuinely have no test attributes", () => {
- const wrapper = render([step("css")]);
- wrapper.find('[data-test="synthetics-journey-testid-misconfigured-dismiss"]').trigger("click");
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(NOTICE).exists()).toBe(false);
- });
- });
-});
diff --git a/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.vue b/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.vue
deleted file mode 100644
index 59ffe55996..0000000000
--- a/web/src/components/synthetics/journey/TestIdMisconfiguredNotice.vue
+++ /dev/null
@@ -1,97 +0,0 @@
-
-
-
-
-
-
-
diff --git a/web/src/components/synthetics/journey/ZeroAssertionNotice.spec.ts b/web/src/components/synthetics/journey/ZeroAssertionNotice.spec.ts
deleted file mode 100644
index c9c82293a3..0000000000
--- a/web/src/components/synthetics/journey/ZeroAssertionNotice.spec.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright 2026 OpenObserve Inc.
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-import { describe, expect, it } from "vitest";
-import { mount } from "@vue/test-utils";
-import { createI18n } from "vue-i18n";
-import type { BrowserStep } from "@/types/synthetics";
-import ZeroAssertionNotice from "./ZeroAssertionNotice.vue";
-import en from "@/locales/languages/en-US.json";
-
-const i18n = createI18n({
- legacy: false,
- locale: "en-US",
- fallbackLocale: "en-US",
- messages: { "en-US": en as Record },
-});
-
-function step(overrides: Partial = {}): BrowserStep {
- return { id: "s1", action: "click", name: "Sign In", ...overrides };
-}
-
-function render(steps: BrowserStep[]) {
- return mount(ZeroAssertionNotice, { props: { steps }, global: { plugins: [i18n] } });
-}
-
-const test = (name: string) => `[data-test="${name}"]`;
-const NOTICE = test("synthetics-journey-zero-assertion-notice");
-
-describe("ZeroAssertionNotice", () => {
- it("warns when a journey verifies nothing", () => {
- expect(render([step()]).find(NOTICE).exists()).toBe(true);
- });
-
- it("stays quiet once the journey asserts something", () => {
- expect(
- render([step(), step({ id: "s2", action: "assert" })])
- .find(NOTICE)
- .exists(),
- ).toBe(false);
- });
-
- it("stays quiet for an empty journey — there is nothing to assert about yet", () => {
- expect(render([]).find(NOTICE).exists()).toBe(false);
- });
-
- // P5.2.1 — the assertion is offered, never generated. A recorder cannot know
- // what "correct" means for an application, so the step arrives with the kind
- // chosen and the target left to the author.
- it("adds an empty element_visible assertion rather than guessing one", async () => {
- const wrapper = render([step()]);
- await wrapper.find(test("synthetics-journey-add-assertion-btn")).trigger("click");
-
- const added = wrapper.emitted("add-assertion")?.[0]?.[0] as BrowserStep;
- expect(added.action).toBe("assert");
- expect(added.assertion).toEqual({ kind: "element_visible" });
- expect(added.selector).toBeUndefined();
- expect(added.locator).toBeUndefined();
- });
-
- // An author who has decided is not told twice.
- it("can be dismissed", async () => {
- const wrapper = render([step()]);
- await wrapper.find(test("synthetics-journey-zero-assertion-dismiss-btn")).trigger("click");
- expect(wrapper.find(NOTICE).exists()).toBe(false);
- });
-});
diff --git a/web/src/components/synthetics/journey/ZeroAssertionNotice.vue b/web/src/components/synthetics/journey/ZeroAssertionNotice.vue
deleted file mode 100644
index 18951c539c..0000000000
--- a/web/src/components/synthetics/journey/ZeroAssertionNotice.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-
-
-
-
-
-
-
diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json
index d3bfbe7728..7715a3ca73 100644
--- a/web/src/locales/languages/en-US.json
+++ b/web/src/locales/languages/en-US.json
@@ -11188,8 +11188,8 @@
"zeroAssertionAdd": "Add an assertion",
"testIdMissingTitle": "No test attributes found in this recording",
"testIdMissingDescription": "The recorder looked for \"{attr}\" and no step matched one, so every step falls back to role, text or CSS selectors — the ones most likely to break when the page changes. If this application marks elements with a different attribute, set it before re-recording.",
- "testIdMissingDismiss": "Dismiss",
- "zeroAssertionDismiss": "Not now",
+ "suggestionCount": "1 suggestion for this journey | {count} suggestions for this journey",
+ "suggestionsNonBlocking": "Suggestions never block saving or running the journey.",
"actionLabel": "Action",
"actionChangedNotice": "Changing the action rebuilds this step from the fields below. The recorded replay detail is not reused.",
"timeoutHelpInteraction": "Blank uses the runner default, {seconds} s. Maximum {max} s.",
diff --git a/web/src/utils/synthetics/deriveLocatorKind.ts b/web/src/utils/synthetics/deriveLocatorKind.ts
index 23135bc522..025118f9e6 100644
--- a/web/src/utils/synthetics/deriveLocatorKind.ts
+++ b/web/src/utils/synthetics/deriveLocatorKind.ts
@@ -32,7 +32,7 @@ import type { LocatorKind } from "@/types/synthetics";
* **Pure function of the value alone — no configuration.** A rule for bare
* attribute selectors (`[data-qa="x"]` → `test_attribute`) was specified and
* removed: it would need the monitor's `testIdAttr`, which the editor cannot see
- * (it reaches only `TestIdMisconfiguredNotice`), and which is mutable config — so
+ * (it reaches only `journeySuggestions`), and which is mutable config — so
* the same string would classify differently after someone edited it. A bare
* attribute selector is also genuinely CSS. The self-describing
* `internal:testid=` form carries the attribute inside the value and is caught by
diff --git a/web/src/utils/synthetics/journeySuggestions.spec.ts b/web/src/utils/synthetics/journeySuggestions.spec.ts
new file mode 100644
index 0000000000..b7c40dc790
--- /dev/null
+++ b/web/src/utils/synthetics/journeySuggestions.spec.ts
@@ -0,0 +1,198 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, expect, it } from "vitest";
+
+import { raw } from "@/types/i18n";
+import type { BrowserStep } from "@/types/synthetics";
+
+import {
+ createSuggestedAssertionStep,
+ deriveJourneySuggestions,
+ type JourneySuggestion,
+} from "./journeySuggestions";
+
+const ATTR = "data-testid";
+
+/** A step that identifies no element — a navigate has nothing to find. */
+function plainStep(overrides: Partial = {}): BrowserStep {
+ return { id: "s1", action: "click", name: "Sign In", ...overrides };
+}
+
+/** A step carrying one locator candidate of the given kind. */
+function locatedStep(kind: string, id = "s1"): BrowserStep {
+ return {
+ id,
+ action: "click",
+ name: "Click",
+ locator: { candidates: [{ kind, value: "x" }] },
+ } as unknown as BrowserStep;
+}
+
+const ids = (suggestions: JourneySuggestion[]) => suggestions.map((s) => s.id);
+
+describe("deriveJourneySuggestions", () => {
+ it("says nothing about an empty journey — there is nothing to advise on yet", () => {
+ expect(deriveJourneySuggestions([], ATTR)).toEqual([]);
+ });
+
+ describe("zero-assertion", () => {
+ // A journey with no assertion can click its way through a broken
+ // application and still report a pass.
+ it("flags a journey that verifies nothing", () => {
+ expect(ids(deriveJourneySuggestions([plainStep()], ATTR))).toContain("zero-assertion");
+ });
+
+ it("stays quiet once the journey asserts something", () => {
+ const steps = [plainStep(), plainStep({ id: "s2", action: "assert" })];
+ expect(ids(deriveJourneySuggestions(steps, ATTR))).not.toContain("zero-assertion");
+ });
+
+ // P5.2.4 — accepted with a warning, never an error.
+ it("is a warning, and offers the assertion as an action", () => {
+ const suggestion = deriveJourneySuggestions([plainStep()], ATTR).find(
+ (s) => s.id === "zero-assertion",
+ );
+
+ expect(suggestion?.severity).toBe("warning");
+ expect(suggestion?.action).toEqual({
+ kind: "add-assertion",
+ labelKey: "synthetics.journey.zeroAssertionAdd",
+ });
+ });
+ });
+
+ describe("no-test-attribute", () => {
+ // Upstream's generator emits nothing at test-id rank when the configured
+ // attribute is not the one the app uses, and every step silently degrades
+ // to role/text/css with no error anywhere.
+ it("flags a recording where no step produced a test_attribute candidate", () => {
+ const steps = [locatedStep("role"), locatedStep("css", "s2")];
+ expect(ids(deriveJourneySuggestions(steps, ATTR))).toContain("no-test-attribute");
+ });
+
+ it("stays quiet when any step found a test attribute", () => {
+ const steps = [locatedStep("css"), locatedStep("test_attribute", "s2")];
+ expect(ids(deriveJourneySuggestions(steps, ATTR))).not.toContain("no-test-attribute");
+ });
+
+ // The generator ranks candidates, so a test attribute is not always first —
+ // its presence anywhere in a step's list is what proves the attribute matched.
+ it("stays quiet when a test attribute is present but outranked within a step", () => {
+ const steps = [
+ {
+ id: "s1",
+ action: "click",
+ name: "Click",
+ locator: {
+ candidates: [
+ { kind: "css", value: "div.row" },
+ { kind: "test_attribute", value: '[data-testid="row"]' },
+ ],
+ },
+ },
+ ] as unknown as BrowserStep[];
+
+ expect(ids(deriveJourneySuggestions(steps, ATTR))).not.toContain("no-test-attribute");
+ });
+
+ it("is a warning, like every suggestion", () => {
+ const suggestion = deriveJourneySuggestions([locatedStep("css")], ATTR).find(
+ (s) => s.id === "no-test-attribute",
+ );
+
+ expect(suggestion?.severity).toBe("warning");
+ });
+
+ it("stays quiet for a journey with no element steps at all", () => {
+ // A navigate-only journey has nothing to find; zero test attributes there
+ // is not evidence of anything.
+ expect(ids(deriveJourneySuggestions([plainStep()], ATTR))).not.toContain("no-test-attribute");
+ });
+
+ it("treats an empty candidate list as no element step, not as a missing attribute", () => {
+ const steps = [
+ { id: "s1", action: "click", name: "Click", locator: { candidates: [] } },
+ ] as unknown as BrowserStep[];
+
+ expect(ids(deriveJourneySuggestions(steps, ATTR))).not.toContain("no-test-attribute");
+ });
+
+ it("names the attribute that was actually used, so the fix is obvious", () => {
+ const suggestion = deriveJourneySuggestions([locatedStep("css")], "data-qa").find(
+ (s) => s.id === "no-test-attribute",
+ );
+
+ expect(suggestion?.descriptionParams).toEqual({ attr: "data-qa" });
+ });
+
+ // The fix is "set the attribute and re-record", which is not one click.
+ it("carries no action", () => {
+ const suggestion = deriveJourneySuggestions([locatedStep("css")], ATTR).find(
+ (s) => s.id === "no-test-attribute",
+ );
+
+ expect(suggestion?.action).toBeUndefined();
+ });
+ });
+
+ it("reports both conditions at once, in a stable order", () => {
+ const steps = [locatedStep("css"), locatedStep("role", "s2")];
+
+ expect(ids(deriveJourneySuggestions(steps, ATTR))).toEqual([
+ "zero-assertion",
+ "no-test-attribute",
+ ]);
+ });
+
+ it("carries i18n keys rather than translated text, so the module stays free of i18n", () => {
+ const suggestions = deriveJourneySuggestions([locatedStep("css")], ATTR);
+
+ expect(suggestions.map((s) => [s.titleKey, s.descriptionKey])).toEqual([
+ ["synthetics.journey.zeroAssertionTitle", "synthetics.journey.zeroAssertionDescription"],
+ ["synthetics.journey.testIdMissingTitle", "synthetics.journey.testIdMissingDescription"],
+ ]);
+ });
+});
+
+describe("createSuggestedAssertionStep", () => {
+ // P5.2.1 — the assertion is offered, never generated. A recorder cannot know
+ // what "correct" means for an application, so the step arrives with the kind
+ // chosen and the target left to the author.
+ it("builds an empty element_visible assertion rather than guessing one", () => {
+ const step = createSuggestedAssertionStep(raw("Verify the final page"));
+
+ expect(step.action).toBe("assert");
+ expect(step.assertion).toEqual({ kind: "element_visible" });
+ expect(step.name).toBe("Verify the final page");
+ expect(step.selector).toBeUndefined();
+ expect(step.locator).toBeUndefined();
+ });
+
+ it("gives every offered step its own id", () => {
+ const a = createSuggestedAssertionStep(raw("Verify the final page"));
+ const b = createSuggestedAssertionStep(raw("Verify the final page"));
+
+ expect(a.id).toBeTruthy();
+ expect(a.id).not.toBe(b.id);
+ });
+
+ it("resolves the journey it was offered for", () => {
+ const steps = [plainStep()];
+ const withAssertion = [...steps, createSuggestedAssertionStep(raw("Verify the final page"))];
+
+ expect(ids(deriveJourneySuggestions(withAssertion, ATTR))).not.toContain("zero-assertion");
+ });
+});
diff --git a/web/src/utils/synthetics/journeySuggestions.ts b/web/src/utils/synthetics/journeySuggestions.ts
new file mode 100644
index 0000000000..f2543af7a2
--- /dev/null
+++ b/web/src/utils/synthetics/journeySuggestions.ts
@@ -0,0 +1,155 @@
+// 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 .
+
+/**
+ * What is worth telling a journey's author about their own recording.
+ *
+ * These used to be two always-on cards stacked above the step list
+ * (`ZeroAssertionNotice.vue`, `TestIdMisconfiguredNotice.vue`). Each computed
+ * its own visibility, rendered its own card and owned a `dismissed` ref that
+ * died on remount — so the author closed the same banner on every visit and the
+ * step list started two cards down the page. The advice was right; the delivery
+ * was not.
+ *
+ * Everything here is a pure derivation over the steps, carrying i18n KEYS rather
+ * than text (`I18nKey`, see `types/i18n.ts`). That keeps the module free of Vue
+ * and of `useI18n`, so a rule can be tested without mounting anything, and the
+ * one surface that renders suggestions decides how they look.
+ *
+ * Adding a rule is one producer in {@link PRODUCERS} and a template that never
+ * changes — which is the property the old design lacked, and the reason two
+ * banners were on their way to becoming five.
+ */
+import type { I18nKey, I18nText } from "@/types/i18n";
+import type { BrowserStep } from "@/types/synthetics";
+import { getUUIDv7 } from "@/utils/zincutils";
+
+export type JourneySuggestionId = "zero-assertion" | "no-test-attribute";
+
+/** The one thing a suggestion can offer to do on the author's behalf. */
+export type JourneySuggestionActionKind = "add-assertion";
+
+export interface JourneySuggestion {
+ id: JourneySuggestionId;
+ /**
+ * Every rule here is advisory. P5.2.4 is explicit that a zero-assertion
+ * journey is *accepted* with a warning — nothing in this module may block a
+ * save. The field exists so a future rule can raise severity without
+ * reshaping the type.
+ */
+ severity: "warning";
+ titleKey: I18nKey;
+ descriptionKey: I18nKey;
+ /** Interpolation for {@link descriptionKey}, when the message takes any. */
+ descriptionParams?: Record;
+ /** Present only when the fix is genuinely one click. */
+ action?: { labelKey: I18nKey; kind: JourneySuggestionActionKind };
+}
+
+type SuggestionProducer = (steps: BrowserStep[], testIdAttr: string) => JourneySuggestion | null;
+
+/**
+ * "This journey verifies nothing."
+ *
+ * A journey with no assertion can click its way through a broken application
+ * and still report a pass — it proves the steps can be performed, not that the
+ * application works. That is the failure mode where a monitor quietly stops
+ * noticing anything, which is worse than a monitor that is obviously broken.
+ *
+ * A warning and not an error (P5.2.4). A monitor that only navigates still
+ * proves the site answers, so refusing to save one would be wrong; but the
+ * author should have to decline the assertion rather than never be offered it.
+ */
+const zeroAssertion: SuggestionProducer = (steps) => {
+ if (steps.length === 0) return null;
+ if (steps.some((s) => s.action === "assert")) return null;
+
+ return {
+ id: "zero-assertion",
+ severity: "warning",
+ titleKey: "synthetics.journey.zeroAssertionTitle",
+ descriptionKey: "synthetics.journey.zeroAssertionDescription",
+ action: { kind: "add-assertion", labelKey: "synthetics.journey.zeroAssertionAdd" },
+ };
+};
+
+/**
+ * "This recording found no test attributes" — the misconfiguration that is
+ * otherwise silent.
+ *
+ * The recorder selects on ONE configured DOM attribute. Playwright defaults to
+ * `data-testid`; O2's own frontend uses `data-test`; a customer may use
+ * `data-qa`, `data-cy` or `data-automation-id`. When the configured attribute is
+ * not the one the application uses, upstream's generator produces NO
+ * `test_attribute` candidates at all and every step quietly degrades to
+ * role/text/css — the least stable ranks — with no error anywhere.
+ *
+ * The signal is unambiguous and cheap: a journey against a page that has test
+ * attributes will produce at least one `test_attribute` candidate. Zero across
+ * an entire recording means the recorder was looking for the wrong attribute, or
+ * the application genuinely has none — and both are worth saying out loud rather
+ * than discovering months later when a locator rots.
+ *
+ * Deliberately not an error: a page really may have no test attributes, and the
+ * journey still works. This tells the author what they are trading away, and
+ * carries no action, because the fix is to change the attribute and re-record.
+ */
+const noTestAttribute: SuggestionProducer = (steps, testIdAttr) => {
+ // Steps that identify an element at all — a navigate has nothing to find, so
+ // its lack of a test attribute is not evidence of anything.
+ const locatorSteps = steps.filter((s) => (s.locator?.candidates?.length ?? 0) > 0);
+ if (locatorSteps.length === 0) return null;
+
+ const hasTestAttribute = locatorSteps.some((s) =>
+ s.locator!.candidates.some((c) => c.kind === "test_attribute"),
+ );
+ if (hasTestAttribute) return null;
+
+ return {
+ id: "no-test-attribute",
+ severity: "warning",
+ titleKey: "synthetics.journey.testIdMissingTitle",
+ descriptionKey: "synthetics.journey.testIdMissingDescription",
+ descriptionParams: { attr: testIdAttr },
+ };
+};
+
+/** Producer order is display order. */
+const PRODUCERS: SuggestionProducer[] = [zeroAssertion, noTestAttribute];
+
+export function deriveJourneySuggestions(
+ steps: BrowserStep[],
+ testIdAttr: string,
+): JourneySuggestion[] {
+ return PRODUCERS.map((produce) => produce(steps, testIdAttr)).filter(
+ (suggestion): suggestion is JourneySuggestion => suggestion !== null,
+ );
+}
+
+/**
+ * The step behind `add-assertion`.
+ *
+ * P5.2.1 — the assertion is offered, never generated. The recorder cannot know
+ * what "correct" means for an application and no vendor pretends otherwise, so
+ * the step arrives with the kind chosen and the target left to the author.
+ */
+export function createSuggestedAssertionStep(name: I18nText): BrowserStep {
+ return {
+ id: getUUIDv7(true),
+ action: "assert",
+ name,
+ assertion: { kind: "element_visible" },
+ };
+}