diff --git a/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx
index 02251ec979..e1f8fb243c 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx
@@ -9,6 +9,7 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
import V2AgentBYO from '../components/V2AgentBYO';
import { AuthContext } from '../../context/AuthContext';
+import { resetByoStepsForTest } from '../utils/byoTelemetry';
jest.mock('axios', () => {
const mock = {
@@ -63,7 +64,7 @@ const renderPage = () => render(
);
beforeEach(() => {
- sessionStorage.clear();
+ sessionStorage.clear(); resetByoStepsForTest();
window.matchMedia = jest.fn().mockReturnValue({ matches: true });
jest.useFakeTimers(); mockGet({ configured: true });
axios.post.mockImplementation((url) => Promise.resolve({ data: url.includes('runtime-tokens') ? { token: 'fixture-only-key' } : { ready: true } }));
@@ -101,7 +102,7 @@ test('page entry is recorded once under StrictMode; command rendering is separat
render();
await screen.findByTestId('byo-mode-byo'); fireEvent.click(screen.getByTestId('byo-mode-byo'));
await waitFor(() => expect(screen.getByRole('button', { name: /get my connect command/i })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: /get my connect command/i })); await screen.findByText(/your connect command for/i);
- expect(axios.post.mock.calls.filter(([url]) => url === '/api/stats/byo-step').map(([, body]) => body)).toEqual([{ step: 'page-viewed' }, { step: 'seat-created' }]);
+ expect(axios.post.mock.calls.filter(([url]) => url === '/api/stats/byo-step').map(([, body]) => body)).toEqual([{ step: 'page-viewed' }, { step: 'hosted-offer-shown' }, { step: 'seat-created' }]);
});
test('a failed clipboard write leaves the fallback available', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockRejectedValue(new Error('Denied')) } });
@@ -172,15 +173,17 @@ const offerEvents = () => axios.post.mock.calls
.map(([, body]) => body);
test.each(['timer', 'exit'])('records offer shown once via %s despite later timers and exit intent', async (trigger) => {
const view = await issue();
- expect(offerEvents()).toEqual([]);
+ expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }]);
if (trigger === 'timer') await tick(30000);
else fireEvent.mouseLeave(document.documentElement, { clientY: 0 });
expect(offer()).toBeInTheDocument();
await tick(60000);
fireEvent.mouseLeave(document.documentElement, { clientY: 0 });
- expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }]);
+ // The first-paint exposure is counted too; the fallback adds its own, once.
+ expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }, { step: 'hosted-offer-shown' }]);
+ // A new page view counts its own first-paint exposure; the fallback does not repeat.
view.unmount(); await issue(); await tick(30000);
- expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }]);
+ expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }, { step: 'hosted-offer-shown' }, { step: 'hosted-offer-shown' }]);
});
test('records accepted offer activation once while hosted lookup is pending', async () => {
await issue(); await tick(30000);
@@ -188,12 +191,28 @@ test('records accepted offer activation once while hosted lookup is pending', as
axios.get.mockImplementation((url) => url.startsWith('/api/registry/agents/next-free-name?')
? new Promise(resolve => { resolveName = resolve; }) : get(url));
const button = offer(); fireEvent.click(button); fireEvent.click(button);
- expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }, { step: 'hosted-offer-clicked' }]);
+ expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }, { step: 'hosted-offer-shown' }, { step: 'hosted-offer-clicked' }]);
await act(async () => resolveName({ data: { name: 'sam-agent' } }));
await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/hosted/provision', { agentName: 'sam-agent' }, expect.anything()));
});
test('copy suppression emits neither hosted-offer event', async () => {
await issue(); fireEvent.copy(screen.getByTestId('byo-listen').querySelector('.v2-byo__command'));
await tick(60000); fireEvent.mouseLeave(document.documentElement, { clientY: 0 });
- expect(offerEvents()).toEqual([]);
+ expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }]);
+});
+test('"Run it here" exposure is counted on first paint, once, and a pick counts as a click', async () => {
+ renderPage(); await screen.findByTestId('byo-mode-hosted');
+ expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }]);
+ fireEvent.click(screen.getByTestId('byo-mode-byo'));
+ fireEvent.click(screen.getByTestId('byo-mode-hosted'));
+ expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }, { step: 'hosted-offer-clicked' }]);
+});
+test('the compact hosted form counts its submit as a click, and the compact BYO form counts no offer', async () => {
+ window.history.pushState({}, '', '/v2/agents/byo?mode=hosted');
+ renderPage();
+ await waitFor(() => expect(document.querySelector('[data-testid="byo-hosted-compact"] .v2-byo__submit')).not.toBeNull());
+ window.history.pushState({}, '', '/');
+ await waitFor(() => expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }]));
+ fireEvent.click(document.querySelector('[data-testid="byo-hosted-compact"] .v2-byo__submit'));
+ expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }, { step: 'hosted-offer-clicked' }]);
});
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index 95caeccef0..0375afc34d 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -906,6 +906,19 @@ const V2AgentBYO: React.FC = () => {
recordByoStep(api, 'page-viewed');
}, [api, currentUser]);
+ // "Run it here" is on screen from first paint whenever the instance hosts
+ // (the chooser, or the compact hosted form), so its exposure is counted
+ // here, once per page view. The post-issue fallback below counts a separate,
+ // later exposure. The compact BYO form has no hosted button, so it is out.
+ const hostedOfferOnScreen = Boolean(hosting?.configured) && !issued && !hosted && !placed
+ && !reconnectRequest && !showCompactByo;
+ const hostedShownCounted = useRef(false);
+ useEffect(() => {
+ if (!hostedOfferOnScreen || hostedShownCounted.current) return;
+ hostedShownCounted.current = true;
+ recordByoStep(api, 'hosted-offer-shown');
+ }, [api, hostedOfferOnScreen]);
+
useEffect(() => {
if (commandViewed.current === issued) return;
commandViewed.current = issued;
@@ -964,7 +977,10 @@ const V2AgentBYO: React.FC = () => {
}
};
- const chooseMode = (nextMode: 'hosted' | 'byo' | 'machine') => setMode(nextMode);
+ const chooseMode = (nextMode: 'hosted' | 'byo' | 'machine') => {
+ if (nextMode === 'hosted') recordByoStep(api, 'hosted-offer-clicked');
+ setMode(nextMode);
+ };
const apiUrl = getApiBaseUrl().replace(/\/+$/, '');
const daemonCommand = daemonSetupCommand(apiUrl);
@@ -1061,7 +1077,10 @@ const V2AgentBYO: React.FC = () => {
{error &&
{error}
}