Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions backend/__tests__/service/onboardingByoOpenedOs.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
7 changes: 6 additions & 1 deletion backend/__tests__/unit/routes/hosted.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
22 changes: 22 additions & 0 deletions backend/__tests__/unit/services/onboardingMarks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -465,6 +467,7 @@ const userSchema = new Schema<IUser>({
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 },
Expand Down
5 changes: 3 additions & 2 deletions backend/routes/hosted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
25 changes: 23 additions & 2 deletions backend/services/onboardingMarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
Expand Down
1 change: 1 addition & 0 deletions docs/runbooks/new-signup-stall.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
51 changes: 50 additions & 1 deletion frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({}, '', '/');
Expand Down Expand Up @@ -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');
});
});
18 changes: 16 additions & 2 deletions frontend/src/v2/components/V2AgentBYO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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<HostedAvailability>('/api/hosted/availability');
// the server (User.onboarding.byoOpenedAt, and byoOpenedOs from ?os).
// Keep it on page load.
const availability = await api.get<HostedAvailability>(`/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
Expand Down
Loading