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