diff --git a/backend/__tests__/service/onboardingByoOpenedOs.test.js b/backend/__tests__/service/onboardingByoOpenedOs.test.js new file mode 100644 index 0000000000..29618e2b3d --- /dev/null +++ b/backend/__tests__/service/onboardingByoOpenedOs.test.js @@ -0,0 +1,59 @@ +/** + * byoOpenedOs round-trips through the real User schema: the enum admits the + * four values markByoOpened writes, and the mark's write-once filter keeps the + * first open's OS. The unit test (unit/services/onboardingMarks.test.js) pins + * the update payload; this pins that the schema stores it. + */ +const User = require('../../models/User'); +const { markByoOpened, resetOnboardingMarksForTest } = require('../../services/onboardingMarks'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); + +const makeUser = (name) => User.create({ + username: name, email: `${name}@example.test`, password: 'Opened-os-123!', +}); + +const settle = async (id, predicate) => { + for (let i = 0; i < 50; i += 1) { + // eslint-disable-next-line no-await-in-loop + const row = await User.findById(id).lean(); + if (predicate(row)) return row; + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, 20)); + } + return User.findById(id).lean(); +}; + +beforeAll(setupMongoDb); +afterAll(closeMongoDb); +beforeEach(async () => { + await clearMongoDb(); + resetOnboardingMarksForTest(); +}); + +test.each(['mac', 'linux', 'windows', 'other'])('stores os=%s with the first-open time', async (os) => { + const user = await makeUser(`opened-${os}`); + markByoOpened(String(user._id), os); + const row = await settle(user._id, (r) => r?.onboarding?.byoOpenedAt); + expect(row.onboarding.byoOpenedAt).toBeInstanceOf(Date); + expect(row.onboarding.byoOpenedOs).toBe(os); +}); + +test('an unknown value records the visit without an OS', async () => { + const user = await makeUser('opened-ios'); + markByoOpened(String(user._id), 'ios'); + const row = await settle(user._id, (r) => r?.onboarding?.byoOpenedAt); + expect(row.onboarding.byoOpenedAt).toBeInstanceOf(Date); + expect(row.onboarding).not.toHaveProperty('byoOpenedOs'); +}); + +test('a later open from another OS does not overwrite the first', async () => { + const user = await makeUser('opened-twice'); + markByoOpened(String(user._id), 'windows'); + const first = await settle(user._id, (r) => r?.onboarding?.byoOpenedAt); + resetOnboardingMarksForTest(); // as after a restart + markByoOpened(String(user._id), 'mac'); + await new Promise((r) => setTimeout(r, 200)); + const row = await User.findById(user._id).lean(); + expect(row.onboarding.byoOpenedOs).toBe('windows'); + expect(row.onboarding.byoOpenedAt).toEqual(first.onboarding.byoOpenedAt); +}); diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index a1bb258979..a1ec1f74e0 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -183,7 +183,12 @@ describe('/api/hosted', () => { const res = await request(app).get('/api/hosted/availability'); expect(res.status).toBe(200); expect(mockMarkByoOpened).toHaveBeenCalledTimes(1); - expect(mockMarkByoOpened).toHaveBeenCalledWith('owner-1'); + expect(mockMarkByoOpened).toHaveBeenCalledWith('owner-1', undefined); + }); + + it('passes the OS the page detected to the first-visit mark', async () => { + await request(app).get('/api/hosted/availability?os=linux'); + expect(mockMarkByoOpened).toHaveBeenCalledWith('owner-1', 'linux'); }); it('checks new-pod hosted availability without marking Bring your own opened', async () => { diff --git a/backend/__tests__/unit/services/onboardingMarks.test.js b/backend/__tests__/unit/services/onboardingMarks.test.js index 460fb1e70d..6e1ff2b195 100644 --- a/backend/__tests__/unit/services/onboardingMarks.test.js +++ b/backend/__tests__/unit/services/onboardingMarks.test.js @@ -139,6 +139,28 @@ describe('markByoOpened', () => { expect(update.$set['onboarding.byoOpenedAt']).toBeInstanceOf(Date); }); + test('records the OS the page detected in the same write-once update', () => { + markByoOpened('u1', 'windows'); + const [filter, update] = mockUpdateOne.mock.calls[0]; + expect(filter).toEqual({ _id: 'u1', isBot: { $ne: true }, 'onboarding.byoOpenedAt': { $exists: false } }); + expect(Object.keys(update.$set)).toEqual(['onboarding.byoOpenedAt', 'onboarding.byoOpenedOs']); + expect(update.$set['onboarding.byoOpenedOs']).toBe('windows'); + }); + + test.each(['mac', 'linux', 'windows', 'other'])('keeps os=%s', (os) => { + markByoOpened('u1', os); + expect(mockUpdateOne.mock.calls[0][1].$set['onboarding.byoOpenedOs']).toBe(os); + }); + + test.each([['ios'], ['Mac'], [''], [['mac']], [{ os: 'mac' }], [42]])( + 'any other os value (%p) records the visit without an OS', + (os) => { + markByoOpened('u1', os); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); + expect(Object.keys(mockUpdateOne.mock.calls[0][1].$set)).toEqual(['onboarding.byoOpenedAt']); + }, + ); + test('no person, no write', () => { markByoOpened(''); markByoOpened(undefined); diff --git a/backend/models/User.ts b/backend/models/User.ts index a55ce06ba0..427715f980 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -210,6 +210,8 @@ export interface IUser extends Document { firstStartId?: string; firstStartAt?: Date; byoOpenedAt?: Date; + /** OS the page detected on that first open, written with byoOpenedAt. */ + byoOpenedOs?: 'mac' | 'linux' | 'windows' | 'other'; /** First BYO setup command copied in the browser, never overwritten. */ byoCommandCopiedAt?: Date; /** Fixed kind only; command contents and tokens are never stored. */ @@ -465,6 +467,7 @@ const userSchema = new Schema({ firstStartId: { type: String }, firstStartAt: { type: Date }, byoOpenedAt: { type: Date }, + byoOpenedOs: { type: String, enum: ['mac', 'linux', 'windows', 'other'] }, byoCommandCopiedAt: { type: Date }, byoCommandType: { type: String, enum: ['mcp', 'cli'] }, byoFirstConnectionAttemptAt: { type: Date }, diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index a931788716..59a61d84c3 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -191,10 +191,11 @@ const relayWorkerError = (res: express.Response, error: any) => { * * Only the Bring your own page calls this, on load, so it also records the * person's first visit to that page (write-once, fire-and-forget; - * services/onboardingMarks.ts). + * services/onboardingMarks.ts). `?os=` is the OS the page detected; the mark + * keeps only mac, linux, windows or other and records no OS for anything else. */ router.get('/availability', hostedRateLimit, auth, async (req: any, res: any) => { - markByoOpened(getUserId(req)); + markByoOpened(getUserId(req), req.query?.os); const engine = engineForNewHostedInstall(); // The caller's own plan line (TASK-268), so the page states the limit they get. const caps = await hostedRuntime.hostedCapsForOwner(getUserId(req)); diff --git a/backend/services/onboardingMarks.ts b/backend/services/onboardingMarks.ts index 762b279b0a..62db8ffb75 100644 --- a/backend/services/onboardingMarks.ts +++ b/backend/services/onboardingMarks.ts @@ -26,6 +26,12 @@ * nothing else calls). It tells "never looked at setup" apart from "looked and * left". * + * byoOpenedOs is written with it, in the same write-once update: the OS the + * page detected from the browser, one of BYO_OPENED_OS. The listener runs only + * on Mac (listenerRunsOn in V2AgentBYO.tsx is os === 'mac'), so this splits + * "left" by whether the listener path could have worked. An unknown or + * missing value records the visit without an OS. + * * Three surface marks come from the browser through POST /api/stats/byo-step, * as fixed step names: inviteCardShownAt and inviteCardDismissedAt for * the "invite a teammate" card, and byoOfferShownAt the first time the thread @@ -102,15 +108,30 @@ function recordFirstStart(id: string, startId: string): void { } } -export function markByoOpened(userId: unknown): void { +export const BYO_OPENED_OS = ['mac', 'linux', 'windows', 'other'] as const; +export type ByoOpenedOs = typeof BYO_OPENED_OS[number]; + +export const parseByoOpenedOs = (value: unknown): ByoOpenedOs | undefined => ( + typeof value === 'string' && (BYO_OPENED_OS as readonly string[]).includes(value) + ? value as ByoOpenedOs + : undefined +); + +export function markByoOpened(userId: unknown, os?: unknown): void { const id = String(userId || ''); if (!id) return; if (byoOpenedMarked.has(id)) return; byoOpenedMarked.add(id); + const detectedOs = parseByoOpenedOs(os); try { User.updateOne( { _id: id, isBot: { $ne: true }, 'onboarding.byoOpenedAt': { $exists: false } }, - { $set: { 'onboarding.byoOpenedAt': new Date() } }, + { + $set: { + 'onboarding.byoOpenedAt': new Date(), + ...(detectedOs ? { 'onboarding.byoOpenedOs': detectedOs } : {}), + }, + }, ).catch(() => { byoOpenedMarked.delete(id); }); diff --git a/docs/runbooks/new-signup-stall.md b/docs/runbooks/new-signup-stall.md index 9b21116288..c71a62803e 100644 --- a/docs/runbooks/new-signup-stall.md +++ b/docs/runbooks/new-signup-stall.md @@ -25,6 +25,7 @@ confirm, for example against `lastActive` or whether they sent a message | `podOpenedAt` | the first authorized first-page fetch (no `before` cursor) of the messages of any pod they created; for a new signup that is normally My Workspace. It is set before the messages are read, so it records the request, not a render | their client asked for a pod's messages; for a new signup, very likely the welcome | | `firstStartAt` + `firstStartId` | their first message in a pod they created matches a start's label, or the server resolves a click on their own "Run an agent here" | they acted, and which choice | | `byoOpenedAt` | they first load Bring your own (`GET /api/hosted/availability`) | they looked at setup | +| `byoOpenedOs` | written with `byoOpenedAt`, from the `?os=` the page sends: `mac`, `linux`, `windows` or `other`. Unset for anyone who opened the page before it shipped, and for an unknown value | whether the listener path (Mac only) could have worked for them | Read them together with whether the person sent a message in their workspace, and when (timestamps and ids). diff --git a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx index a7db131b17..f0d3762c11 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx @@ -197,7 +197,7 @@ describe('V2AgentBYO — Run it here', () => { ); await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toBeInTheDocument()); // let the availability effect settle, then assert it did not stomp the mode - await waitFor(() => expect(axios.get.mock.calls.some(([url]) => url === '/api/hosted/availability')).toBe(true)); + await waitFor(() => expect(axios.get.mock.calls.some(([url]) => url.startsWith('/api/hosted/availability'))).toBe(true)); await waitFor(() => expect(screen.getByTestId('byo-mode-byo')).toHaveAttribute('aria-pressed', 'true')); expect(screen.getByTestId('byo-mode-hosted')).toHaveAttribute('aria-pressed', 'false'); window.history.pushState({}, '', '/'); @@ -482,3 +482,52 @@ describe('V2AgentBYO — Run it here', () => { expect(axios.post.mock.calls[0][1]).toMatchObject({ config: { runtime: { runtimeType: 'webhook' } } }); }); }); + +// The page's load request carries the OS it detected, for the server's +// first-open mark (User.onboarding.byoOpenedOs). Unlike the setup tabs, an +// OS it can't place is reported as 'other', never as a Mac. +describe('V2AgentBYO — detected OS on the availability request', () => { + const availabilityUrl = () => axios.get.mock.calls + .map(([url]) => url) + .find((url) => url.startsWith('/api/hosted/availability')); + + const withPlatform = async (platform, userAgent, touchPoints = 0) => { + const uaData = Object.getOwnPropertyDescriptor(window.navigator, 'userAgentData'); + const ua = jest.spyOn(window.navigator, 'userAgent', 'get'); + if (userAgent) ua.mockReturnValue(userAgent); + const touch = Object.getOwnPropertyDescriptor(window.navigator, 'maxTouchPoints'); + Object.defineProperty(window.navigator, 'maxTouchPoints', { value: touchPoints, configurable: true }); + Object.defineProperty(window.navigator, 'userAgentData', { + value: platform === undefined ? undefined : { platform }, configurable: true, + }); + try { + mockGet({ configured: true }); + renderPage(); + await waitFor(() => expect(availabilityUrl()).toBeDefined()); + return availabilityUrl(); + } finally { + ua.mockRestore(); + if (touch) Object.defineProperty(window.navigator, 'maxTouchPoints', touch); + else delete window.navigator.maxTouchPoints; + if (uaData) Object.defineProperty(window.navigator, 'userAgentData', uaData); + else delete window.navigator.userAgentData; + } + }; + + test.each([ + ['macOS', undefined, 'mac'], + ['Windows', undefined, 'windows'], + ['Linux', undefined, 'linux'], + ['Android', undefined, 'other'], + [undefined, 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', 'other'], + [undefined, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', 'mac'], + [undefined, 'Mozilla/5.0 (linux) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/20.0.3', 'other'], + ])('platform %p / UA %p reports os=%s', async (platform, userAgent, os) => { + expect(await withPlatform(platform, userAgent)).toBe(`/api/hosted/availability?os=${os}`); + }); + + test('an iPad in desktop mode (Mac UA, touch) reports os=other', async () => { + expect(await withPlatform(undefined, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', 5)) + .toBe('/api/hosted/availability?os=other'); + }); +}); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 99c33c7c53..0209d03c4f 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -92,6 +92,19 @@ const browserOs = (): SetupOs => { if (/X11|CrOS|Linux (x86_64|aarch64|armv|i686)|^Linux$/.test(platform) && !/Android/.test(platform)) return 'linux'; return 'mac'; }; +// What the page saw, for the first-open mark only (byoOpenedOs). Unlike +// browserOs it does not default to mac: a browser it can't place is 'other'. +type OpenedOs = SetupOs | 'other'; +const openedOs = (): OpenedOs => { + if (typeof navigator === 'undefined') return 'other'; + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const platform = nav.userAgentData?.platform || nav.userAgent || ''; + if (/iPhone|iPad|Android/.test(platform)) return 'other'; + // iPadOS Safari in desktop mode reports a Mac user agent; touch gives it away. + if (/Mac|macOS/.test(platform)) return (nav.maxTouchPoints || 0) > 1 ? 'other' : 'mac'; + const os = browserOs(); + return os === 'mac' ? 'other' : os; +}; const SETUP_OS_OPTIONS: Array<{ os: SetupOs; labelKey: string }> = [ { os: 'mac', labelKey: 'agentByo.listen.osMac' }, { os: 'linux', labelKey: 'agentByo.listen.osLinux' }, @@ -526,8 +539,9 @@ const V2AgentBYO: React.FC = () => { } try { // This request also records the person's first visit to this page on - // the server (User.onboarding.byoOpenedAt). Keep it on page load. - const availability = await api.get('/api/hosted/availability'); + // the server (User.onboarding.byoOpenedAt, and byoOpenedOs from ?os). + // Keep it on page load. + const availability = await api.get(`/api/hosted/availability?os=${openedOs()}`); if (cancelled) return; if (availability && typeof availability.configured === 'boolean') { // Sets no mode. This effect re-runs on every pod change (podId is a