From 62528b4c121f6d4f27b57de3d07807275dca8d4e Mon Sep 17 00:00:00 2001 From: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:07:17 +0530 Subject: [PATCH] fix(synthetics): sequential recorder setup checklist with state-safe refresh and incognito failure recovery (#13684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up to #13658 — redesigns the recorder setup flow as a sequential-gated checklist and closes the UX holes around refresh, stale connections, and incognito failures. ### Sequential-gated setup checklist (shared by the create-page setup phase and the journey dialog) - Only one task is actionable at a time; later tasks render locked, done tasks collapse to a green check row. - **Task 1 (install)**: Web Store button + an "I've added it to Chrome" attestation (the install happens in another tab the page can't observe). Live bridge detection auto-completes it ("Detected automatically"); checkbox-only completion offers Undo. - **Task 2 (incognito)**: "Do this in Chrome" callout with inline Chrome-UI icons + first-person attestation, collapsing to "Allowed in Incognito" with Undo. - **Task 3 (connect)**: completes only on the real probe signal — attestations can't skip into a recording that would hang. Icon-click is the primary instruction (injects without reloading); refresh is the secondary link. - Journey dialog: per-action subtitle, "n of 3 done" badge, full-width CTA reading " — finish the tasks above first" while locked, a hint naming the exact blocking task, skip link (record only). - Attestations are session-only by design — persisting them would keep tasks pre-completed after the extension is removed. ### State-safe refresh + gate bypass - Entering the setup phase mirrors url/name into the query (`setup=1`); mount restores them, so a mid-setup F5 returns to the checklist with fields intact. - The checklist's "refresh this page" strips the setup flag; when url+name are present and the warm probe detects the extension, the gate auto-commits straight into the editor (recording stays a deliberate click). `?url=&name=` deep links work as a byproduct. ### Incognito failure recovery - Both the replay preflight classification and the recording-start refusal (the extension's raw "Recording needs incognito access…" message) now revoke the disproved attestation and auto-open the setup dialog on its incognito task, with the failed action as the CTA. The warning card slims to Retry / Dismiss / Show setup steps (its chrome://extensions walkthrough duplicated the checklist, and `window.open("chrome://…")` is blocked by Chrome anyway). ### Compulsory re-verification after the incognito toggle - Toggling "Allow in Incognito" **reloads the extension and orphans the tab's bridge** (per the takeover-handshake contract in the extension's content script), so giving the ack invalidates `extensionReady` and re-probes — the connect task stays open until a fresh probe passes. The install fact is latched first so task 1 doesn't regress. ## Test plan - 141 tests across the four specs: ExtensionSetupChecklist 18, ExtensionSetupDialog 19, BrowserJourney 60, CreateBrowserTest 44 — including the deferred-probe assertions that the CTA follows the *fresh* probe verdict, not stale state - `npm run lint` / `npm run type-check` / `npm run lint:design:strict` clean - Manual: full install → incognito → connect flow against the published extension, both failure paths (record + replay without incognito), refresh at each task --- .../ExtensionSetupChecklist.spec.ts | 239 ++++++++++--- .../synthetics/ExtensionSetupChecklist.vue | 313 +++++++++++++----- .../synthetics/journey/BrowserJourney.spec.ts | 180 +++++++++- .../synthetics/journey/BrowserJourney.vue | 96 +++--- .../journey/ExtensionSetupDialog.spec.ts | 279 +++++++++++----- .../journey/ExtensionSetupDialog.vue | 119 ++++++- web/src/constants/synthetics.ts | 21 ++ web/src/locales/languages/en-US.json | 54 +-- .../synthetics/CreateBrowserTest.spec.ts | 231 ++++++++++++- .../views/synthetics/CreateBrowserTest.vue | 112 ++++++- 10 files changed, 1339 insertions(+), 305 deletions(-) diff --git a/web/src/components/synthetics/ExtensionSetupChecklist.spec.ts b/web/src/components/synthetics/ExtensionSetupChecklist.spec.ts index 90ff5d300a..d60eb7daef 100644 --- a/web/src/components/synthetics/ExtensionSetupChecklist.spec.ts +++ b/web/src/components/synthetics/ExtensionSetupChecklist.spec.ts @@ -24,21 +24,34 @@ import { CHROME_WEB_STORE_URL } from "@/constants/synthetics"; import store from "@/test/unit/helpers/store"; import ExtensionSetupChecklist from "./ExtensionSetupChecklist.vue"; -// Stubs emit native-component click so parent @click handlers fire. +// Forwards disabled and re-emits click so parent @click handlers fire. const OButtonStub = { - template: '', + props: ["disabled"], + emits: ["click"], + template: + '', }; -// Surfaces the bound model as a checkbox so the v-model round-trip is testable. -const OSwitchStub = { - props: ["modelValue", "label"], + +// Surfaces the bound model as a native checkbox so the ack round-trips are testable. +const OCheckboxStub = { + props: ["modelValue"], emits: ["update:modelValue"], template: - '', + '', +}; + +// i18n-t interpolates Chrome UI labels into its slots; the raw keypath is +// enough for text assertions here. +const I18nTStub = { + props: ["keypath"], + template: "{{ keypath }}", }; const STUBS = { OButton: OButtonStub, - OSwitch: OSwitchStub, + OCheckbox: OCheckboxStub, + OIcon: true, + "i18n-t": I18nTStub, }; function mountChecklist(props: Record = {}) { @@ -51,74 +64,200 @@ function mountChecklist(props: Record = {}) { describe("ExtensionSetupChecklist", () => { let wrapper: VueWrapper; let openSpy: ReturnType; + let replaceMock: ReturnType; + const originalLocation = window.location; beforeEach(() => { openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + // jsdom's navigation methods throw "Not implemented"; replaced wholesale, + // as in http.spec.ts, because the property itself is read-only. href is + // spelled out — location's accessors don't survive the object spread. + replaceMock = vi.fn(); + Object.defineProperty(window, "location", { + value: { + ...originalLocation, + href: "http://localhost:3000/synthetics/browser/new?url=https%3A%2F%2Fexample.com&name=My+Check&setup=1", + replace: replaceMock, + }, + writable: true, + configurable: true, + }); }); afterEach(() => { wrapper?.unmount(); vi.restoreAllMocks(); - }); - - it("should open the Chrome Web Store in a new tab from the install button", async () => { - wrapper = mountChecklist(); - - await wrapper.find('[data-test="synthetics-setup-install-btn"]').trigger("click"); - - expect(openSpy).toHaveBeenCalledWith(CHROME_WEB_STORE_URL, "_blank", "noopener"); - }); - - it("should prefer the /config extension URL over the built-in fallback", async () => { - const configUrl = "https://chromewebstore.google.com/detail/custom-build"; - store.state.zoConfig.synthetics_recorder_extension_url = configUrl; - wrapper = mountChecklist(); - - await wrapper.find('[data-test="synthetics-setup-install-btn"]').trigger("click"); - - expect(openSpy).toHaveBeenCalledWith(configUrl, "_blank", "noopener"); + Object.defineProperty(window, "location", { + value: originalLocation, + writable: true, + configurable: true, + }); + // Cleaned up here (not inline in the test) so a failing assertion cannot + // leak the config URL into later tests. delete store.state.zoConfig.synthetics_recorder_extension_url; }); - it("should emit update:incognitoDone when the incognito switch is toggled on", async () => { - wrapper = mountChecklist(); + describe("task 1 — install", () => { + it("should open the Chrome Web Store in a new tab from the install button", async () => { + wrapper = mountChecklist(); - await wrapper.find('[data-test="synthetics-setup-incognito-switch"]').setValue(true); + await wrapper.find('[data-test="synthetics-setup-install-btn"]').trigger("click"); - expect(wrapper.emitted("update:incognitoDone")).toEqual([[true]]); + expect(openSpy).toHaveBeenCalledWith(CHROME_WEB_STORE_URL, "_blank", "noopener"); + }); + + it("should prefer the /config extension URL over the built-in fallback", async () => { + const configUrl = "https://chromewebstore.google.com/detail/custom-build"; + store.state.zoConfig.synthetics_recorder_extension_url = configUrl; + wrapper = mountChecklist(); + + await wrapper.find('[data-test="synthetics-setup-install-btn"]').trigger("click"); + + expect(openSpy).toHaveBeenCalledWith(configUrl, "_blank", "noopener"); + }); + + it("should emit update:installAck true when the install attestation is checked", async () => { + wrapper = mountChecklist(); + + await wrapper.find('[data-test="synthetics-setup-install-ack"]').setValue(true); + + expect(wrapper.emitted("update:installAck")).toEqual([[true]]); + }); + + it("should not emit true for a truthy non-boolean install ack value", async () => { + wrapper = mountChecklist(); + + // Task 2 is locked here, so the install ack is the only checkbox rendered. + await wrapper.findComponent(OCheckboxStub).vm.$emit("update:modelValue", "indeterminate"); + + const emitted = wrapper.emitted("update:installAck") ?? []; + expect(emitted.flat()).not.toContain(true); + // Still pending, so the attestation checkbox stays on screen. + expect(wrapper.find('[data-test="synthetics-setup-install-ack"]').exists()).toBe(true); + }); + + it("should collapse to an auto-detected done row (no undo) once connected", () => { + wrapper = mountChecklist({ connected: true }); + + expect(wrapper.find('[data-test="synthetics-setup-install-btn"]').exists()).toBe(false); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupInstallDone"); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupDetectedAuto"); + expect(wrapper.find('[data-test="synthetics-setup-install-undo"]').exists()).toBe(false); + }); + + it("should collapse to a done row with Undo when done via attestation alone", () => { + wrapper = mountChecklist({ installAck: true }); + + expect(wrapper.find('[data-test="synthetics-setup-install-btn"]').exists()).toBe(false); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupInstallDone"); + // The auto-detected marker is the probe's word, not the author's. + expect(wrapper.text()).not.toContain("synthetics.createBrowserTest.setupDetectedAuto"); + expect(wrapper.find('[data-test="synthetics-setup-install-undo"]').exists()).toBe(true); + }); + + it("should emit update:installAck false when the install Undo is clicked", async () => { + wrapper = mountChecklist({ installAck: true }); + + await wrapper.find('[data-test="synthetics-setup-install-undo"]').trigger("click"); + + expect(wrapper.emitted("update:installAck")).toEqual([[false]]); + }); }); - it("should emit update:incognitoDone false when the switch is toggled back off", async () => { - wrapper = mountChecklist({ incognitoDone: true }); + describe("task 2 — incognito attestation", () => { + it("should render locked without a checkbox until the install task is done", () => { + wrapper = mountChecklist(); - await wrapper.find('[data-test="synthetics-setup-incognito-switch"]').setValue(false); + expect(wrapper.find('[data-test="synthetics-setup-incognito-ack"]').exists()).toBe(false); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupIncognitoTitle"); + }); - expect(wrapper.emitted("update:incognitoDone")).toEqual([[false]]); + it("should unlock via the install attestation alone, without a connection", () => { + wrapper = mountChecklist({ installAck: true }); + + expect(wrapper.find('[data-test="synthetics-setup-incognito-ack"]').exists()).toBe(true); + }); + + it("should emit update:incognitoDone true when the ack checkbox is checked", async () => { + wrapper = mountChecklist({ connected: true }); + + await wrapper.find('[data-test="synthetics-setup-incognito-ack"]').setValue(true); + + expect(wrapper.emitted("update:incognitoDone")).toEqual([[true]]); + }); + + it("should not emit true for a truthy non-boolean incognito ack value", async () => { + wrapper = mountChecklist({ connected: true }); + + // Task 1 is collapsed here, so the incognito ack is the only checkbox rendered. + await wrapper.findComponent(OCheckboxStub).vm.$emit("update:modelValue", "indeterminate"); + + const emitted = wrapper.emitted("update:incognitoDone") ?? []; + expect(emitted.flat()).not.toContain(true); + expect(wrapper.find('[data-test="synthetics-setup-incognito-ack"]').exists()).toBe(true); + }); + + it("should collapse to a done row with an Undo action once acknowledged", () => { + wrapper = mountChecklist({ connected: true, incognitoDone: true }); + + expect(wrapper.find('[data-test="synthetics-setup-incognito-ack"]').exists()).toBe(false); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupIncognitoDoneTitle"); + expect(wrapper.find('[data-test="synthetics-setup-incognito-undo"]').exists()).toBe(true); + }); + + it("should emit update:incognitoDone false when Undo is clicked", async () => { + wrapper = mountChecklist({ connected: true, incognitoDone: true }); + + await wrapper.find('[data-test="synthetics-setup-incognito-undo"]').trigger("click"); + + expect(wrapper.emitted("update:incognitoDone")).toEqual([[false]]); + }); }); - it("should reflect the incognitoDone model on the switch", () => { - wrapper = mountChecklist({ incognitoDone: true }); + describe("task 3 — connect", () => { + it("should show connect as done only on the real probe signal", () => { + wrapper = mountChecklist({ connected: true, incognitoDone: true }); - const el = wrapper.find('[data-test="synthetics-setup-incognito-switch"]') - .element as HTMLInputElement; - expect(el.checked).toBe(true); - }); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupConnectDone"); + expect(wrapper.find('[data-test="synthetics-setup-refresh-btn"]').exists()).toBe(false); + }); - it("should show the connected line when the extension is connected", () => { - wrapper = mountChecklist({ connected: true }); + it("should become active when both attestations are done but the probe is silent", () => { + wrapper = mountChecklist({ installAck: true, incognitoDone: true }); - expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupConnected"); - }); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupConnectTitle"); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupConnectAlt"); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupConnectWaiting"); + expect(wrapper.text()).not.toContain("synthetics.createBrowserTest.setupConnectDone"); + expect(wrapper.find('[data-test="synthetics-setup-refresh-btn"]').exists()).toBe(true); + }); - it("should not show the connected line when the extension is not connected", () => { - wrapper = mountChecklist({ connected: false }); + it("should reload without the setup flag, keeping the other query params", async () => { + wrapper = mountChecklist({ installAck: true, incognitoDone: true }); - expect(wrapper.text()).not.toContain("synthetics.createBrowserTest.setupConnected"); - }); + await wrapper.find('[data-test="synthetics-setup-refresh-btn"]').trigger("click"); - it("should not show the connected line when connected is omitted", () => { - wrapper = mountChecklist(); + expect(replaceMock).toHaveBeenCalledTimes(1); + const target = new URL(replaceMock.mock.calls[0][0] as string); + expect(target.searchParams.get("setup")).toBeNull(); + expect(target.searchParams.get("url")).toBe("https://example.com"); + expect(target.searchParams.get("name")).toBe("My Check"); + }); - expect(wrapper.text()).not.toContain("synthetics.createBrowserTest.setupConnected"); + it("should stay locked while the incognito ack is pending", () => { + wrapper = mountChecklist({ connected: true, incognitoDone: false }); + + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupConnectTitle"); + expect(wrapper.text()).not.toContain("synthetics.createBrowserTest.setupConnectDone"); + expect(wrapper.find('[data-test="synthetics-setup-refresh-btn"]').exists()).toBe(false); + }); + + it("should stay locked while nothing is done", () => { + wrapper = mountChecklist(); + + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupConnectTitle"); + expect(wrapper.text()).not.toContain("synthetics.createBrowserTest.setupConnectDone"); + expect(wrapper.find('[data-test="synthetics-setup-refresh-btn"]').exists()).toBe(false); + }); }); }); diff --git a/web/src/components/synthetics/ExtensionSetupChecklist.vue b/web/src/components/synthetics/ExtensionSetupChecklist.vue index 9669a3a73b..b43e4c290e 100644 --- a/web/src/components/synthetics/ExtensionSetupChecklist.vue +++ b/web/src/components/synthetics/ExtensionSetupChecklist.vue @@ -15,94 +15,175 @@ along with this program. If not, see . --> diff --git a/web/src/components/synthetics/journey/BrowserJourney.spec.ts b/web/src/components/synthetics/journey/BrowserJourney.spec.ts index 6f981ca7b8..e0ef13e14d 100644 --- a/web/src/components/synthetics/journey/BrowserJourney.spec.ts +++ b/web/src/components/synthetics/journey/BrowserJourney.spec.ts @@ -96,7 +96,7 @@ const ConfirmDialogStub = { // without pulling in the real ODialog. const ExtensionSetupDialogStub = { props: ["open", "connected", "action"], - emits: ["update:open", "continue"], + emits: ["update:open", "continue", "verify"], template: '
', }; @@ -217,6 +217,16 @@ describe("BrowserJourney recording", () => { expect(wrapper.emitted("replay")).toBeTruthy(); }); + // The parent owns the connection state, so the dialog's re-verify request + // (incognito toggle reloads the extension → stale bridge) is forwarded up. + it("should forward the setup dialog's verify as verify-extension", async () => { + wrapper = mountJourney({ extensionReady: false }); + + await wrapper.findComponent(ExtensionSetupDialogStub).vm.$emit("verify"); + + expect(wrapper.emitted("verify-extension")).toHaveLength(1); + }); + it("should start recording and render streamed live steps", async () => { wrapper = mountJourney({ extensionReady: true, startUrl: "https://app.test/login" }); @@ -1101,3 +1111,171 @@ describe("BrowserJourney — stopping a replay", () => { expect(wrapper.find('[data-test="synthetics-journey-add-step-btn"]').exists()).toBe(false); }); }); + +// An incognito-classified failure — replay preflight (blocked-reason prop) or a +// recording-start refusal (recorder.error) — means "Allow in Incognito" is off. +// The setup dialog's incognito task IS the walkthrough, so the view reopens it +// (revoking the attestation the failure just disproved) instead of a wall of text. +describe("BrowserJourney — incognito preflight failure", () => { + let wrapper: VueWrapper; + let revokeSpy: ReturnType; + + // The extension's actual refusal message — classification keys on "incognito". + const INCOGNITO_RECORD_ERROR = + 'Recording needs incognito access. Open chrome://extensions, find "OpenObserve Synthetics Recorder", and turn on "Allow in incognito" — recordings always run in a separate incognito window.'; + + // The template ref resolves to this stub, so revokeIncognitoAck must exist on + // its instance for the watcher's call to be observable (options-API methods + // are exposed on the vm). + const RevokableSetupDialogStub = { + props: ["open", "connected", "action"], + emits: ["update:open", "continue"], + methods: { + revokeIncognitoAck() { + revokeSpy(); + }, + }, + template: '
', + }; + + // The shared OButtonStub leaves `onClick` in $attrs (no declared emits), so a + // click fires the handler twice — declaring the emit keeps it to one, which + // the exactly-once assertions below depend on. + const SingleEmitButtonStub = { + emits: ["click"], + template: '', + }; + + function mountBlockable(props: Record = {}) { + return mount(BrowserJourney, { + props: { modelValue: [], ...props }, + global: { + stubs: { + ...STUBS, + OButton: SingleEmitButtonStub, + ExtensionSetupDialog: RevokableSetupDialogStub, + }, + }, + }) as VueWrapper; + } + + /** Drive a recording-start refusal through the real bridge transport. */ + async function failRecordingWith(w: VueWrapper, message: string) { + await w.find('[data-test="synthetics-journey-record-btn"]').trigger("click"); + await settleProbeDelay(); + respondToLastCommand({ success: false, error: message }); + await flushPromises(); + } + + beforeEach(() => { + revokeSpy = vi.fn(); + postMessageSpy = vi.fn(); + vi.spyOn(window, "postMessage").mockImplementation(postMessageSpy); + vi.useFakeTimers(); + }); + + afterEach(() => { + wrapper?.unmount(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("should revoke the attestation and open the setup dialog on replay when blocked", async () => { + wrapper = mountBlockable({ blockedReason: null }); + expect(wrapper.find(".extension-setup-dialog-stub").exists()).toBe(false); + + await wrapper.setProps({ blockedReason: "incognito" }); + + expect(revokeSpy).toHaveBeenCalledTimes(1); + const dialog = wrapper.find(".extension-setup-dialog-stub"); + expect(dialog.exists()).toBe(true); + expect(dialog.attributes("data-action")).toBe("replay"); + }); + + it("should not touch the dialog for other blocked reasons", async () => { + wrapper = mountBlockable({ blockedReason: null }); + + await wrapper.setProps({ blockedReason: "in-progress" }); + + expect(revokeSpy).not.toHaveBeenCalled(); + expect(wrapper.find(".extension-setup-dialog-stub").exists()).toBe(false); + }); + + it("should reopen the setup dialog from the warning card's setup button", async () => { + wrapper = mountBlockable({ blockedReason: "incognito" }); + + await wrapper.find('[data-test="synthetics-journey-incognito-setup-btn"]').trigger("click"); + + const dialog = wrapper.find(".extension-setup-dialog-stub"); + expect(dialog.exists()).toBe(true); + expect(dialog.attributes("data-action")).toBe("replay"); + }); + + it("should emit replay from the warning card's Retry", async () => { + wrapper = mountBlockable({ blockedReason: "incognito" }); + + await wrapper.find('[data-test="synthetics-journey-incognito-retry-btn"]').trigger("click"); + + expect(wrapper.emitted("replay")).toHaveLength(1); + }); + + it("should emit clear-results from the warning card's Dismiss", async () => { + wrapper = mountBlockable({ blockedReason: "incognito" }); + + await wrapper.find('[data-test="synthetics-journey-incognito-dismiss-btn"]').trigger("click"); + + expect(wrapper.emitted("clear-results")).toHaveLength(1); + expect(wrapper.emitted("replay")).toBeFalsy(); + }); + + it("should treat an incognito recording refusal like the replay preflight", async () => { + wrapper = mountBlockable({ extensionReady: true }); + + await failRecordingWith(wrapper, INCOGNITO_RECORD_ERROR); + + expect(revokeSpy).toHaveBeenCalledTimes(1); + const dialog = wrapper.find(".extension-setup-dialog-stub"); + expect(dialog.exists()).toBe(true); + expect(dialog.attributes("data-action")).toBe("record"); + expect(wrapper.find('[data-test="synthetics-journey-incognito-warning"]').exists()).toBe(true); + // The card and dialog replace the raw banner for the one cause with a fix. + expect(wrapper.find('[data-test="synthetics-journey-record-error"]').exists()).toBe(false); + }); + + it("should keep the raw banner for recording errors that are not incognito", async () => { + wrapper = mountBlockable({ extensionReady: true }); + + await failRecordingWith(wrapper, "Failed to start recording."); + + expect(wrapper.find('[data-test="synthetics-journey-record-error"]').exists()).toBe(true); + expect(revokeSpy).not.toHaveBeenCalled(); + expect(wrapper.find(".extension-setup-dialog-stub").exists()).toBe(false); + expect(wrapper.find('[data-test="synthetics-journey-incognito-warning"]').exists()).toBe(false); + }); + + it("should restart the recording (not replay) from Retry when recording was refused", async () => { + wrapper = mountBlockable({ extensionReady: true }); + await failRecordingWith(wrapper, INCOGNITO_RECORD_ERROR); + postMessageSpy.mockClear(); + + await wrapper.find('[data-test="synthetics-journey-incognito-retry-btn"]').trigger("click"); + + // startRecording opens with a synchronous bridge probe — its signature. + expect( + postMessageSpy.mock.calls.some((c) => (c[0] as { ch?: string })?.ch === "oo-bridge-probe"), + ).toBe(true); + expect(wrapper.emitted("replay")).toBeFalsy(); + }); + + it("should clear the recorder error (not results) from Dismiss when recording was refused", async () => { + wrapper = mountBlockable({ extensionReady: true }); + await failRecordingWith(wrapper, INCOGNITO_RECORD_ERROR); + + await wrapper.find('[data-test="synthetics-journey-incognito-dismiss-btn"]').trigger("click"); + + expect(wrapper.emitted("clear-results")).toBeFalsy(); + // Error gone: the card collapses and the raw banner does not take its place. + expect(wrapper.find('[data-test="synthetics-journey-incognito-warning"]').exists()).toBe(false); + expect(wrapper.find('[data-test="synthetics-journey-record-error"]').exists()).toBe(false); + }); +}); diff --git a/web/src/components/synthetics/journey/BrowserJourney.vue b/web/src/components/synthetics/journey/BrowserJourney.vue index 3dd6285147..884a04d4f9 100644 --- a/web/src/components/synthetics/journey/BrowserJourney.vue +++ b/web/src/components/synthetics/journey/BrowserJourney.vue @@ -36,6 +36,7 @@ import BrowserJourneyStepEditor from "./BrowserJourneyStepEditor.vue"; import BrowserJourneyStepError from "./BrowserJourneyStepError.vue"; import ExtensionSetupDialog from "./ExtensionSetupDialog.vue"; import { stepIsMissingTarget } from "@/utils/synthetics/stepTarget"; +import { classifyPreflightFailure } from "@/utils/synthetics/replayFailure"; const props = defineProps<{ modelValue: BrowserStep[]; @@ -91,6 +92,11 @@ const emit = defineEmits<{ "stop-replay": []; "auto-record-consumed": []; "selection-changed": [{ count: number; isRecording: boolean }]; + /** + * The setup dialog's incognito ack was just given — the toggle reloads the + * extension, so the owner of `extensionReady` must invalidate and re-probe. + */ + "verify-extension": []; }>(); // ── Filter / expand / select state ────────────────────────────────────────── @@ -272,14 +278,6 @@ const multiSelectEnabled = computed( // reflects its reactive state and merges the result into the journey on stop. const { t } = useI18nTyped(); -// Chrome UI element names — must stay in English across all locales -// because they reference the actual Chrome browser interface. -const CHROME_UI = { - details: "Details", - allowIncognito: "Allow in Incognito", - recorderName: "OpenObserve Recorder", -} as const; - const recorder = useSyntheticsRecorder(); const isRecording = recorder.isRecording; const capturedSteps = recorder.liveSteps; @@ -507,6 +505,35 @@ function onExtensionSetupContinue() { else emit("replay"); } +const extensionSetupDialog = ref | null>(null); + +// Recording-start refusals arrive as a raw extension message, not a +// BlockedReason — classify them the same way the replay preflight is. +const recordingBlockedIncognito = computed( + () => !!recordingError.value && classifyPreflightFailure(recordingError.value) === "incognito", +); + +// An incognito-classified failure — replay preflight or recording start — +// means "Allow in Incognito" is off: reopen the setup dialog on its incognito +// task, revoking the attestation the failure just disproved, instead of +// leaving the author with a wall of text. +watch([() => props.blockedReason, recordingBlockedIncognito], ([reason, recordBlocked]) => { + if (reason === "incognito" || recordBlocked) { + extensionSetupDialog.value?.revokeIncognitoAck(); + extensionSetup.value = { open: true, action: recordBlocked ? "record" : "replay" }; + } +}); + +function onIncognitoRetry() { + if (recordingBlockedIncognito.value) startRecording(); + else emit("replay"); +} + +function onIncognitoDismiss() { + if (recordingBlockedIncognito.value) recorder.error.value = ""; + else emit("clear-results"); +} + /** Sync stop — called by parent's route guard before navigating away. * Commits captured recording steps so they aren't lost. Returns true if * anything was stopped. */ @@ -748,16 +775,6 @@ function handleStepReplace(row: BrowserStep, next: BrowserStep) { } emit("update:modelValue", steps); } - -function openChromeExtensions() { - const url = "chrome://extensions"; - try { - window.open(url, "_blank")?.focus(); - } catch { - /* ignore */ - } - navigator.clipboard.writeText(url).catch(() => {}); -} diff --git a/web/src/components/synthetics/journey/ExtensionSetupDialog.spec.ts b/web/src/components/synthetics/journey/ExtensionSetupDialog.spec.ts index fd9c2671c9..76ac2148e2 100644 --- a/web/src/components/synthetics/journey/ExtensionSetupDialog.spec.ts +++ b/web/src/components/synthetics/journey/ExtensionSetupDialog.spec.ts @@ -20,57 +20,74 @@ vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }), })); +import store from "@/test/unit/helpers/store"; import ExtensionSetupDialog from "./ExtensionSetupDialog.vue"; // ODialog teleports its content to document.body, which would put every -// assertion below outside the wrapper's tree. Stubbed to render inline and -// surface the primary button contract (label, disabled) in the DOM. +// assertion below outside the wrapper's tree. Stubbed to render its +// header-right, default, and footer slots inline — and only while open. const ODialogStub = { name: "ODialog", - props: [ - "open", - "title", - "size", - "primaryButtonLabel", - "primaryButtonDisabled", - "secondaryButtonLabel", - ], - emits: ["update:open", "click:primary", "click:secondary"], + props: ["open", "title", "subTitle", "size"], + emits: ["update:open"], template: ` -
+
+ -
`, }; -// Surfaces the connected pass-through and lets tests flip the incognito model -// through the checklist's own event contract. +// Surfaces the connected pass-through and lets tests flip both attestations +// through the checklist's own model contracts. const ChecklistStub = { name: "ExtensionSetupChecklist", - props: ["connected", "incognitoDone"], - emits: ["update:incognitoDone"], + props: ["connected", "installAck", "incognitoDone"], + emits: ["update:installAck", "update:incognitoDone"], template: '
', }; +// Forwards disabled and re-emits click so CTA assertions and handlers work. +const OButtonStub = { + props: ["disabled"], + emits: ["click"], + template: + '', +}; + +const OBadgeStub = { + template: '', +}; + const STUBS = { ODialog: ODialogStub, ExtensionSetupChecklist: ChecklistStub, + OButton: OButtonStub, + OBadge: OBadgeStub, }; function mountDialog(props: Record = {}) { return mount(ExtensionSetupDialog, { props: { open: true, action: "record", ...props }, - global: { stubs: STUBS }, + global: { plugins: [store], stubs: STUBS }, }) as VueWrapper; } -async function confirmIncognito(wrapper: VueWrapper) { - await wrapper.findComponent(ChecklistStub).vm.$emit("update:incognitoDone", true); +// Flips an attestation via the checklist's v-models. +async function setInstallAck(wrapper: VueWrapper, value: boolean) { + wrapper.findComponent(ChecklistStub).vm.$emit("update:installAck", value); + await wrapper.vm.$nextTick(); } -function primaryDisabled(wrapper: VueWrapper): boolean { - return wrapper.find('[data-test="stub-primary"]').attributes("disabled") !== undefined; +async function setIncognito(wrapper: VueWrapper, value: boolean) { + wrapper.findComponent(ChecklistStub).vm.$emit("update:incognitoDone", value); + await wrapper.vm.$nextTick(); +} + +function ctaDisabled(wrapper: VueWrapper): boolean { + return ( + wrapper.find('[data-test="synthetics-setup-continue-btn"]').attributes("disabled") !== undefined + ); } describe("ExtensionSetupDialog", () => { @@ -81,67 +98,183 @@ describe("ExtensionSetupDialog", () => { vi.restoreAllMocks(); }); - it("should label the primary button Record for the record action", () => { - wrapper = mountDialog({ action: "record" }); + it("should render nothing while closed", () => { + wrapper = mountDialog({ open: false }); - expect(wrapper.find(".dialog-stub").attributes("data-primary-label")).toBe( - "synthetics.journey.record", + expect(wrapper.find('[data-test="synthetics-setup-continue-btn"]').exists()).toBe(false); + }); + + it("should show the progress badge in the dialog header", () => { + wrapper = mountDialog(); + + expect(wrapper.find('[data-test="synthetics-setup-progress"]').text()).toContain( + "synthetics.createBrowserTest.setupProgress", ); }); - it("should label the primary button Replay for the replay action", () => { - wrapper = mountDialog({ action: "replay" }); - - expect(wrapper.find(".dialog-stub").attributes("data-primary-label")).toBe( - "synthetics.journey.replay", - ); - }); - - it("should disable the primary button when neither step is done", () => { - wrapper = mountDialog({ connected: false }); - - expect(primaryDisabled(wrapper)).toBe(true); - }); - - it("should keep the primary button disabled when connected but incognito is unconfirmed", () => { + it("should pass the connected state through to the checklist", () => { wrapper = mountDialog({ connected: true }); - expect(primaryDisabled(wrapper)).toBe(true); + expect(wrapper.find(".checklist-stub").attributes("data-connected")).toBe("true"); }); - it("should keep the primary button disabled when incognito is confirmed but not connected", async () => { - wrapper = mountDialog({ connected: false }); + describe("primary CTA gating", () => { + it("should disable the CTA and point at install while nothing is done", () => { + wrapper = mountDialog({ connected: false }); - await confirmIncognito(wrapper); + expect(ctaDisabled(wrapper)).toBe(true); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupHintInstall"); + }); - expect(primaryDisabled(wrapper)).toBe(true); + it("should point at incognito once installed via attestation alone", async () => { + wrapper = mountDialog({ connected: false }); + + await setInstallAck(wrapper, true); + + expect(ctaDisabled(wrapper)).toBe(true); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupHintIncognito"); + }); + + it("should point at incognito once connected", () => { + wrapper = mountDialog({ connected: true }); + + expect(ctaDisabled(wrapper)).toBe(true); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupHintIncognito"); + }); + + // The attestations alone must never enable the CTA — only the live probe + // proves the recorder can actually drive this tab. + it("should keep the CTA disabled on both acks without a connection", async () => { + wrapper = mountDialog({ connected: false }); + + await setInstallAck(wrapper, true); + await setIncognito(wrapper, true); + + expect(ctaDisabled(wrapper)).toBe(true); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupHintConnect"); + }); + + it("should show the locked label while any task is pending", () => { + wrapper = mountDialog({ connected: false }); + + expect(wrapper.find('[data-test="synthetics-setup-continue-btn"]').text()).toContain( + "synthetics.createBrowserTest.setupCtaLocked", + ); + }); + + it("should enable the CTA with the action label once connected and acknowledged", async () => { + wrapper = mountDialog({ connected: true }); + + await setIncognito(wrapper, true); + + expect(ctaDisabled(wrapper)).toBe(false); + expect(wrapper.find('[data-test="synthetics-setup-continue-btn"]').text()).toContain( + "synthetics.journey.record", + ); + expect(wrapper.text()).not.toContain("synthetics.createBrowserTest.setupHintInstall"); + expect(wrapper.text()).not.toContain("synthetics.createBrowserTest.setupHintIncognito"); + expect(wrapper.text()).not.toContain("synthetics.createBrowserTest.setupHintConnect"); + }); + + it("should emit continue and then close on CTA click", async () => { + wrapper = mountDialog({ connected: true }); + await setIncognito(wrapper, true); + + await wrapper.find('[data-test="synthetics-setup-continue-btn"]').trigger("click"); + + expect(wrapper.emitted("continue")).toHaveLength(1); + expect(wrapper.emitted("update:open")).toEqual([[false]]); + }); }); - it("should enable the primary button once connected and incognito is confirmed", async () => { + describe("skip link", () => { + it("should close without continuing when skipped from the record flow", async () => { + wrapper = mountDialog({ action: "record" }); + + await wrapper.find('[data-test="synthetics-setup-dialog-skip"]').trigger("click"); + + expect(wrapper.emitted("update:open")).toEqual([[false]]); + expect(wrapper.emitted("continue")).toBeFalsy(); + }); + + it("should not offer a skip link for the replay flow", () => { + wrapper = mountDialog({ action: "replay" }); + + expect(wrapper.find('[data-test="synthetics-setup-dialog-skip"]').exists()).toBe(false); + }); + }); + + // Toggling "Allow in Incognito" reloads the extension and orphans the tab's + // bridge — a pre-toggle connection is stale. Giving the ack must hand the + // invalidation upstream (verify) while latching the install fact, which is + // not in doubt, so task 1 does not regress when `connected` drops. + describe("incognito ack — connection re-verify", () => { + it("should emit verify when the incognito attestation is given", async () => { + wrapper = mountDialog({ connected: true }); + + await setIncognito(wrapper, true); + + expect(wrapper.emitted("verify")).toHaveLength(1); + }); + + it("should latch the install ack on the incognito ack while connected", async () => { + wrapper = mountDialog({ connected: true }); + expect(wrapper.findComponent(ChecklistStub).props("installAck")).toBe(false); + + await setIncognito(wrapper, true); + + expect(wrapper.findComponent(ChecklistStub).props("installAck")).toBe(true); + }); + + it("should still emit verify without latching when not connected", async () => { + wrapper = mountDialog({ connected: false }); + + await setIncognito(wrapper, true); + + expect(wrapper.emitted("verify")).toHaveLength(1); + // No connection means no proven install — nothing to latch. + expect(wrapper.findComponent(ChecklistStub).props("installAck")).toBe(false); + }); + + it("should not emit verify when the ack is revoked or repeated", async () => { + wrapper = mountDialog({ connected: true }); + await setIncognito(wrapper, true); + + await setIncognito(wrapper, false); + await setIncognito(wrapper, true); + + // Only the two false→true transitions verify — the revoke does not. + expect(wrapper.emitted("verify")).toHaveLength(2); + }); + }); + + // A preflight failure proves "Allow in Incognito" is actually off — the + // exposed revoke clears the attestation so the checklist re-asks instead of + // showing all done. + it("should re-ask for the incognito attestation when revokeIncognitoAck is called", async () => { wrapper = mountDialog({ connected: true }); + await setIncognito(wrapper, true); + expect(ctaDisabled(wrapper)).toBe(false); - await confirmIncognito(wrapper); + (wrapper.vm as unknown as { revokeIncognitoAck: () => void }).revokeIncognitoAck(); + await wrapper.vm.$nextTick(); - expect(primaryDisabled(wrapper)).toBe(false); + expect(ctaDisabled(wrapper)).toBe(true); + expect(wrapper.text()).toContain("synthetics.createBrowserTest.setupHintIncognito"); }); - it("should emit continue and close on primary click", async () => { + // The acks live in component-lifetime refs — session-only on purpose, but a + // dismissed dialog must not ask the author to attest twice. + it("should keep both attestations across a close and re-open", async () => { wrapper = mountDialog({ connected: true }); - await confirmIncognito(wrapper); + await setInstallAck(wrapper, true); + await setIncognito(wrapper, true); - await wrapper.find('[data-test="stub-primary"]').trigger("click"); + await wrapper.setProps({ open: false }); + await wrapper.setProps({ open: true }); - expect(wrapper.emitted("continue")).toHaveLength(1); - expect(wrapper.emitted("update:open")).toEqual([[false]]); - }); - - it("should close without continuing on secondary click", async () => { - wrapper = mountDialog({ connected: true }); - - await wrapper.find('[data-test="stub-secondary"]').trigger("click"); - - expect(wrapper.emitted("update:open")).toEqual([[false]]); - expect(wrapper.emitted("continue")).toBeFalsy(); + expect(ctaDisabled(wrapper)).toBe(false); + expect(wrapper.text()).not.toContain("synthetics.createBrowserTest.setupHintIncognito"); }); it("should forward the dialog's own update:open", async () => { @@ -151,22 +284,4 @@ describe("ExtensionSetupDialog", () => { expect(wrapper.emitted("update:open")).toEqual([[false]]); }); - - it("should pass the connected state through to the checklist", () => { - wrapper = mountDialog({ connected: true }); - - expect(wrapper.find(".checklist-stub").attributes("data-connected")).toBe("true"); - }); - - // The incognito confirmation is the one step that cannot be probed, so a - // dismissed dialog must not ask the author to confirm it twice. - it("should keep the incognito confirmation across a close and re-open", async () => { - wrapper = mountDialog({ connected: true }); - await confirmIncognito(wrapper); - - await wrapper.setProps({ open: false }); - await wrapper.setProps({ open: true }); - - expect(primaryDisabled(wrapper)).toBe(false); - }); }); diff --git a/web/src/components/synthetics/journey/ExtensionSetupDialog.vue b/web/src/components/synthetics/journey/ExtensionSetupDialog.vue index 75744e5ebc..bdf8e7e2bc 100644 --- a/web/src/components/synthetics/journey/ExtensionSetupDialog.vue +++ b/web/src/components/synthetics/journey/ExtensionSetupDialog.vue @@ -15,10 +15,13 @@ along with this program. If not, see . --> diff --git a/web/src/constants/synthetics.ts b/web/src/constants/synthetics.ts index 94a20628a3..b84e1b9bb6 100644 --- a/web/src/constants/synthetics.ts +++ b/web/src/constants/synthetics.ts @@ -257,6 +257,27 @@ export const VALUE_TOOLTIP_KEYS: Record = { export const CHROME_WEB_STORE_URL = "https://chromewebstore.google.com/detail/afhgiecgbpohkbobialnajlphbpcgomo"; +/** + * Query flag CreateBrowserTest writes when entering the extension-setup phase + * so a mid-setup reload (F5) returns there with the gate fields restored. The + * checklist's own "refresh this page" action strips it on purpose — after that + * reload the recorder is connected, so the gate's Record goes straight to + * recording instead of re-entering setup. + */ +export const SETUP_QUERY_PARAM = "setup"; + +/** + * Chrome UI element names referenced by the recorder setup flow — interpolated + * into i18n strings as params so they stay in English across all locales, + * matching the actual Chrome browser interface. + */ +export const CHROME_UI_LABELS = { + allowIncognito: "Allow in Incognito", + extensionsMenu: "Extensions", + manageExtension: "Manage extension", + recorderName: "OpenObserve Recorder", +} as const; + // ── Recorder locator configuration ─────────────────────────────────────────── /** diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index b6a9c8dbef..1e47c0dab8 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -10561,28 +10561,44 @@ "loading": "Loading check…", "nameHint": "Auto-filled from the page -- edit if you like.", "newBrowserCheck": "New browser check", + "setupConnectAlt": "click {name} in the {icon} {menu} menu or", + "setupConnectDescription": "One quick refresh lets the recorder attach to this page.", + "setupConnectDone": "Connected", + "setupConnectRefreshCta": "Refresh this page", + "setupConnectTitle": "Connect the recorder to this tab", + "setupConnectWaiting": "Waiting for the recorder — this flips to {connected} by itself.", + "setupCtaLocked": "{action} — finish the tasks above first", "setupDescription": "We'll open {url} in a fresh incognito tab and capture your clicks and inputs -- no code.", - "setupIncognitoDone": "Done", + "setupDetectedAuto": "Detected automatically", + "setupHintConnect": "{action} unlocks once the recorder connects to this tab.", + "setupHintIncognito": "Confirm {setting} (task 2) to keep going.", + "setupHintInstall": "Install the recorder to unlock the next task.", + "setupIncognitoAckLabel": "I've turned on {setting}", + "setupIncognitoAckNote": "Chrome doesn't let us check this setting, so confirm it yourself once it's on.", + "setupIncognitoCalloutStep1": "Click the {icon} {menu} menu in the toolbar → {more} next to {name} → {manage}.", + "setupIncognitoCalloutStep2": "Turn on {setting}.", + "setupIncognitoCalloutTitle": "Do this in Chrome", + "setupIncognitoDescription": "Recordings run in a clean Incognito window, and Chrome blocks extensions there unless you allow it. This is a Chrome setting — you flip it, not us.", + "setupIncognitoDoneTitle": "Allowed in Incognito", + "setupIncognitoTitle": "Allow the recorder in Incognito", + "setupInstallAckLabel": "I've added it to Chrome", + "setupInstallAckNote": "The install happens in another tab where this page can't see it, so confirm it here once Chrome finishes.", + "setupInstallCta": "Install from Chrome Web Store", + "setupInstallDescription": "A lightweight Chrome extension that captures your clicks and inputs.", + "setupInstallDone": "Recorder installed", + "setupInstallTitle": "Install the OpenObserve Recorder", "setupOpenRecord": "Open & Record", + "setupProgress": "{done} of {total} done", "setupRecorder": "Set up the recorder", "setupSkip": "Skip -- I'll build the steps manually", - "setupInstallCta": "Install from Chrome Web Store", - "setupStep1Description": "A lightweight Chrome extension that captures your actions.", - "setupStep1Title": "Install the OpenObserve Recorder", + "setupSubtitleRecord": "3 things to do before your first recording — takes about a minute.", + "setupSubtitleReplay": "3 things to do before your first replay — takes about a minute.", + "setupUndo": "Undo", "startingUrl": "Starting URL", "stepConfigure": "Configure", "stepJourney": "Journey", "untitledCheck": "Untitled check", - "variablesHint": "Supports {variables} like {baseUrl}.", - "setupStep2Title": "Connect the recorder to this tab", - "setupStep2Point1": "Click on the {icon} {menu} menu in Chrome's toolbar (top right) and select {name}.", - "setupStep2Point2": "Or simply refresh this page.", - "setupStep2Point3": "The status below flips to {connected} automatically.", - "setupStep3Title": "Enable incognito mode", - "setupStep3Description": "Journeys record and replay in an Incognito window, so Chrome needs {setting} enabled for the recorder.", - "setupStep3Point1": "Click on the {icon} {menu} menu in Chrome's toolbar, then click {more} next to {name} and select {manage}.", - "setupStep3Point2": "Toggle {setting} on — the recorder can't interact with pages opened without it.", - "setupConnected": "Connected ✓" + "variablesHint": "Supports {variables} like {baseUrl}." }, "dialog": { "bulkDeleteBody": "Are you sure you want to delete {count} check? This action cannot be undone. | Are you sure you want to delete {count} checks? This action cannot be undone.", @@ -10774,7 +10790,6 @@ "noSteps": "No steps yet", "filterClearedForErrors": "Filter cleared so the steps that need fixing are visible.", "filterClearedForNewStep": "Filter cleared so the new step is visible.", - "openExtensions": "Open chrome://extensions", "preflightDescription": "The recorder extension stopped before running the first step. Its report is below.", "preflightTitle": "Replay couldn't start", "replayInProgressDescription": "Another replay is still running in the recorder extension. Wait for it to finish, or reload the extension, then try again.", @@ -10782,7 +10797,6 @@ "reRun": "Re-run", "reRunToHere": "Re-run steps 1–{step}", "fidelityLabel": "The preview could not reproduce", - "extensionDialogDescription": "Recording and replaying journeys uses the OpenObserve Recorder Chrome extension. Complete the steps below to continue.", "record": "Record", "recordJourney": "Record journey", "recording": "Recording", @@ -10816,12 +10830,8 @@ "timeoutLabel": "Timeout (ms)", "timeoutPlaceholder": "30000", "valueFallback": "Value", - "waitingForActions": "Waiting for actions in the browser…", - "incognitoStep1Full": "Open chrome://extensions", - "incognitoStep2Full": "Find the {name} extension", - "incognitoStep3Full": "Click {button}", - "incognitoStep4Full": "Enable {setting}", - "incognitoStep5Full": "Return here and click {button}" + "showSetupSteps": "Show setup steps", + "waitingForActions": "Waiting for actions in the browser…" }, "labels": { "copyOf": "Copy of {name}", diff --git a/web/src/views/synthetics/CreateBrowserTest.spec.ts b/web/src/views/synthetics/CreateBrowserTest.spec.ts index cce26737bc..5642d98d72 100644 --- a/web/src/views/synthetics/CreateBrowserTest.spec.ts +++ b/web/src/views/synthetics/CreateBrowserTest.spec.ts @@ -29,9 +29,11 @@ const { mockServiceUpdate, mockServiceGet, mockRouterPush, + mockRouterReplace, mockToast, mockRecorderStopReplay, mockRecorderReplayPhase, + mockDetectExtension, mockGetFoldersListByType, mockRoute, } = vi.hoisted(() => ({ @@ -42,11 +44,15 @@ const { mockServiceUpdate: vi.fn().mockResolvedValue({}), mockServiceGet: vi.fn().mockResolvedValue({ data: {} }), mockRouterPush: vi.fn(), + mockRouterReplace: vi.fn(), mockToast: vi.fn(() => vi.fn()), // Shared so a test can assert the view delegates the stop instead of driving // the phase itself. The composable owns stopping → stopped. mockRecorderStopReplay: vi.fn().mockResolvedValue(undefined), mockRecorderReplayPhase: { value: "idle" }, + // Shared so tests can flip the warm extension probe (mount) and the Record + // click probe — both go through recorder.detectExtension. + mockDetectExtension: vi.fn().mockResolvedValue(false), mockGetFoldersListByType: vi.fn().mockResolvedValue([]), // Mutable so a test can drive `?folder=` — the preselected folder is read // from the route on mount. @@ -57,14 +63,14 @@ vi.mock("vue-router", () => ({ useRoute: () => mockRoute, useRouter: () => ({ push: mockRouterPush, - replace: vi.fn(), + replace: mockRouterReplace, }), onBeforeRouteLeave: vi.fn(), })); vi.mock("@/composables/useSyntheticsRecorder", () => ({ default: () => ({ - detectExtension: vi.fn().mockResolvedValue(false), + detectExtension: mockDetectExtension, replayPhase: mockRecorderReplayPhase, stepResults: new Map(), activeStepId: { value: null }, @@ -115,7 +121,9 @@ vi.mock("@/components/synthetics/CreateBrowserTest.schema", () => { return { makeBrowserCheckGateSchema: (_t: any) => z.object({ - url: z.string().min(1, "URL is required"), + // `.url()` so the query-restore tests can distinguish an invalid URL + // from a merely empty one, like the real schema does. + url: z.string().min(1, "URL is required").url("Invalid URL"), name: z.string().optional(), }), makeBrowserCheckSaveSchema: (_t: any) => @@ -319,6 +327,9 @@ describe("CreateBrowserTest", () => { mockServiceUpdate.mockResolvedValue({}); mockServiceGet.mockResolvedValue({ data: {} }); mockGetFoldersListByType.mockResolvedValue([]); + // Re-primed here because clearAllMocks keeps implementations — a test that + // resolves the probe true must not leak into the next one. + mockDetectExtension.mockResolvedValue(false); mockRoute.query = {}; }); @@ -835,4 +846,218 @@ describe("CreateBrowserTest", () => { expect(configuredCheck(wrapper).folder).toBe("folder-1"); }); }); + + // The extension setup flow's own guidance leads the author through a page + // refresh, which remounts this view. The gate fields are mirrored into the + // query on entering setup so the refresh restores them and returns to the + // setup phase instead of restarting the wizard. The attestation checkboxes + // deliberately do NOT survive — install re-verifies through live detection. + describe("create mode — gate restore via query params", () => { + const gateUrlInput = (w: VueWrapper) => w.find('[data-test="synthetics-create-url-input"]'); + const gateNameInput = (w: VueWrapper) => w.find('[data-test="synthetics-create-name-input"]'); + const onSetupPhase = (w: VueWrapper) => + w.find('[data-test="synthetics-setup-open-record-btn"]').exists(); + + it("should mirror the trimmed gate fields into the query when Record enters setup", async () => { + mockRoute.query = { folder: "folder-1" }; + wrapper = mountPage(); + await flushPromises(); + + await gateUrlInput(wrapper).setValue(" https://example.com "); + await gateNameInput(wrapper).setValue(" My Check "); + await wrapper.find('[data-test="synthetics-create-record-btn"]').trigger("click"); + await flushPromises(); + + expect(onSetupPhase(wrapper)).toBe(true); + // Existing params (like ?folder=) survive the merge. + expect(mockRouterReplace).toHaveBeenCalledWith({ + query: { folder: "folder-1", url: "https://example.com", name: "My Check", setup: "1" }, + }); + }); + + it("should omit the name param when the name is blank", async () => { + wrapper = mountPage(); + await flushPromises(); + + await gateUrlInput(wrapper).setValue("https://example.com"); + await wrapper.find('[data-test="synthetics-create-record-btn"]').trigger("click"); + await flushPromises(); + + expect(mockRouterReplace).toHaveBeenCalledWith({ + query: { url: "https://example.com", setup: "1" }, + }); + }); + + it("should restore the gate and land in the setup phase on mount with a valid url and setup=1", async () => { + mockRoute.query = { url: "https://example.com", name: "Restored Check", setup: "1" }; + + wrapper = mountPage(); + await flushPromises(); + + expect(onSetupPhase(wrapper)).toBe(true); + // The gate was committed with the restored fields, not skipped over. + expect((wrapper.vm as any).check.url).toBe("https://example.com"); + expect((wrapper.vm as any).check.name).toBe("Restored Check"); + }); + + it("should stay on the gate when setup=1 but the url is invalid", async () => { + mockRoute.query = { url: "not-a-url", setup: "1" }; + + wrapper = mountPage(); + await flushPromises(); + + expect(onSetupPhase(wrapper)).toBe(false); + // The bad value is still prefilled for the author to correct. + expect((gateUrlInput(wrapper).element as HTMLInputElement).value).toBe("not-a-url"); + }); + + it("should prefill the gate without advancing when the setup flag is absent", async () => { + mockRoute.query = { url: "https://example.com", name: "Restored Check" }; + + wrapper = mountPage(); + await flushPromises(); + + expect(onSetupPhase(wrapper)).toBe(false); + expect((gateUrlInput(wrapper).element as HTMLInputElement).value).toBe("https://example.com"); + expect((gateNameInput(wrapper).element as HTMLInputElement).value).toBe("Restored Check"); + }); + }); + + // A deep link carrying BOTH gate fields has nothing left to ask once the + // warm probe confirms the extension — the gate commits itself and the wizard + // jumps straight to the editor. The probe is the gatekeeper: attestations or + // a lone url must never trigger the jump. + describe("create mode — gate bypass on prefilled deep link", () => { + const onGate = (w: VueWrapper) => w.find('[data-test="synthetics-create-url-input"]').exists(); + const onSetupPhase = (w: VueWrapper) => + w.find('[data-test="synthetics-setup-open-record-btn"]').exists(); + const journey = (w: VueWrapper) => w.find('[data-test="synthetics-browser-journey"]'); + + it("should commit the gate and land in the editor when the probe confirms the extension", async () => { + mockRoute.query = { url: "https://example.com", name: "Deep Link Check" }; + mockDetectExtension.mockResolvedValue(true); + + wrapper = mountPage(); + await flushPromises(); + + expect(onGate(wrapper)).toBe(false); + expect(onSetupPhase(wrapper)).toBe(false); + expect(journey(wrapper).exists()).toBe(true); + expect((wrapper.vm as any).check.url).toBe("https://example.com"); + expect((wrapper.vm as any).check.name).toBe("Deep Link Check"); + // The bypass opens the editor idle — recording stays a deliberate click. + expect( + wrapper.findComponent('[data-test="synthetics-browser-journey"]').props("autoRecord"), + ).toBe(false); + }); + + it("should stay on the gate with prefilled fields when the probe finds no extension", async () => { + mockRoute.query = { url: "https://example.com", name: "Deep Link Check" }; + mockDetectExtension.mockResolvedValue(false); + + wrapper = mountPage(); + await flushPromises(); + + expect(onGate(wrapper)).toBe(true); + expect(journey(wrapper).exists()).toBe(false); + expect( + (wrapper.find('[data-test="synthetics-create-url-input"]').element as HTMLInputElement) + .value, + ).toBe("https://example.com"); + expect( + (wrapper.find('[data-test="synthetics-create-name-input"]').element as HTMLInputElement) + .value, + ).toBe("Deep Link Check"); + }); + + it("should stay on the gate when only the url param is present", async () => { + mockRoute.query = { url: "https://example.com" }; + mockDetectExtension.mockResolvedValue(true); + + wrapper = mountPage(); + await flushPromises(); + + expect(onGate(wrapper)).toBe(true); + expect(journey(wrapper).exists()).toBe(false); + }); + + it("should let setup=1 take precedence over the bypass", async () => { + mockRoute.query = { url: "https://example.com", name: "Deep Link Check", setup: "1" }; + mockDetectExtension.mockResolvedValue(true); + + wrapper = mountPage(); + await flushPromises(); + + expect(onSetupPhase(wrapper)).toBe(true); + expect(journey(wrapper).exists()).toBe(false); + }); + }); + + // Toggling "Allow in Incognito" reloads the extension and orphans the tab's + // bridge, so a connection proven before the toggle proves nothing after it. + // Giving the incognito ack must trigger a FRESH probe, and the setup CTA must + // follow that probe's result — never the stale connection state. + describe("extension setup phase — re-verify on incognito ack", () => { + /** Gate → Record with no extension lands on the full-page setup phase. */ + async function mountAtSetupPhase() { + const w = mountPage(); + await flushPromises(); + await w.find('[data-test="synthetics-create-url-input"]').setValue("https://example.com"); + await w.find('[data-test="synthetics-create-record-btn"]').trigger("click"); + await flushPromises(); + return w; + } + + // The real checklist renders here; the real OCheckbox's root is a