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
20 changes: 20 additions & 0 deletions backend/__tests__/unit/routes/stats.byo-step.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const request = require('supertest');
const express = require('express');

const mockMarkByoCommandCopied = jest.fn();
const mockMarkSurface = jest.fn();
const mockInsertOne = jest.fn().mockResolvedValue({ acknowledged: true });
const mockPassThrough = () => (_req, _res, next) => next();

Expand All @@ -18,6 +19,7 @@ jest.mock('../../../middleware/auth', () => (req, _res, next) => {
});
jest.mock('../../../services/onboardingMarks', () => ({
markByoCommandCopied: (...args) => mockMarkByoCommandCopied(...args),
markSurface: (...args) => mockMarkSurface(...args),
}));
jest.mock('express-rate-limit', () => ({
__esModule: true,
Expand Down Expand Up @@ -66,13 +68,31 @@ describe('POST /api/stats/byo-step', () => {
expect(mockInsertOne).toHaveBeenCalledWith({ userId: 'human-1', step, at: expect.any(Date) });
});

it.each([
['invite-card-shown', 'inviteCardShownAt'],
['invite-card-dismissed', 'inviteCardDismissedAt'],
['byo-offer-shown', 'byoOfferShownAt'],
])('%s sets the %s mark and logs only the fixed step', async (step, mark) => {
const res = await request(app).post('/api/stats/byo-step').send({ step, podId: 'p1' });
expect(res.status).toBe(200);
expect(mockMarkSurface).toHaveBeenCalledWith('human-1', mark);
expect(mockMarkByoCommandCopied).not.toHaveBeenCalled();
expect(mockInsertOne).toHaveBeenCalledWith({ userId: 'human-1', step, at: expect.any(Date) });
});

it('other steps set no surface mark', async () => {
await request(app).post('/api/stats/byo-step').send({ step: 'page-viewed' });
expect(mockMarkSurface).not.toHaveBeenCalled();
});

it('rejects unknown steps without writing a mark or telemetry event', async () => {
const res = await request(app)
.post('/api/stats/byo-step')
.send({ step: 'command-copied' });

expect(res.status).toBe(400);
expect(mockMarkByoCommandCopied).not.toHaveBeenCalled();
expect(mockMarkSurface).not.toHaveBeenCalled();
expect(mockInsertOne).not.toHaveBeenCalled();
});
});
75 changes: 75 additions & 0 deletions backend/__tests__/unit/services/onboardingMarks.db.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// D139: markSurface against the real User schema. The sibling suite mocks
// User, so it cannot see a field the schema would strip in strict mode.
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');

let mongod;
let User;
let markSurface;
let resetOnboardingMarksForTest;

const readOnboarding = async (id) => (await User.findById(id).lean()).onboarding || {};
const waitForField = async (id, field) => {
for (let i = 0; i < 50; i += 1) {
// eslint-disable-next-line no-await-in-loop
const onboarding = await readOnboarding(id);
if (onboarding[field]) return onboarding;
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 20));
}
throw new Error(`onboarding.${field} was never written`);
};
const settle = () => new Promise((r) => setTimeout(r, 100));

beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri());
User = require('../../../models/User');
({ markSurface, resetOnboardingMarksForTest } = require('../../../services/onboardingMarks'));
});
afterEach(async () => {
resetOnboardingMarksForTest();
await User.deleteMany({});
});
afterAll(async () => {
await mongoose.disconnect();
await mongod.stop();
});

const makeUser = (extra = {}) => User.create({
username: `maya${Math.floor(Math.random() * 1e9)}`,
email: `maya${Math.floor(Math.random() * 1e9)}@example.test`,
password: 'not-a-real-password-hash',
...extra,
});

describe('markSurface on the real User schema', () => {
test.each(['inviteCardShownAt', 'inviteCardDismissedAt', 'byoOfferShownAt'])('%s persists', async (mark) => {
const user = await makeUser();
markSurface(user._id, mark);
const onboarding = await waitForField(user._id, mark);
expect(onboarding[mark]).toBeInstanceOf(Date);
});

test('a second mark after a process reset keeps the first time and leaves other fields alone', async () => {
const podOpenedAt = new Date('2026-10-01T00:00:00Z');
const user = await makeUser({ onboarding: { podOpenedAt } });
markSurface(user._id, 'inviteCardShownAt');
const first = (await waitForField(user._id, 'inviteCardShownAt')).inviteCardShownAt;

resetOnboardingMarksForTest();
markSurface(user._id, 'inviteCardShownAt');
await settle();

const onboarding = await readOnboarding(user._id);
expect(onboarding.inviteCardShownAt).toEqual(first);
expect(onboarding.podOpenedAt).toEqual(podOpenedAt);
});

test('a bot user is never marked', async () => {
const bot = await makeUser({ isBot: true });
markSurface(bot._id, 'byoOfferShownAt');
await settle();
expect((await readOnboarding(bot._id)).byoOfferShownAt).toBeUndefined();
});
});
40 changes: 39 additions & 1 deletion backend/__tests__/unit/services/onboardingMarks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jest.mock('../../../models/User', () => ({

const {
markPodOpened, markStartPicked, markByoOpened, markByoCommandCopied,
markByoConnectionAttempt, markWelcomeHostedStart, resetOnboardingMarksForTest,
markByoConnectionAttempt, markWelcomeHostedStart, markSurface, resetOnboardingMarksForTest,
} = require('../../../services/onboardingMarks');

const flush = () => new Promise((r) => setImmediate(r));
Expand Down Expand Up @@ -203,6 +203,44 @@ describe('BYO setup marks', () => {
});
});

describe('markSurface (D139)', () => {
test.each(['inviteCardShownAt', 'inviteCardDismissedAt', 'byoOfferShownAt'])('%s is write-once in the filter, never for bots', (mark) => {
markSurface('u1', mark);
expect(mockUpdateOne).toHaveBeenCalledTimes(1);
const [filter, update] = mockUpdateOne.mock.calls[0];
expect(filter).toEqual({ _id: 'u1', isBot: { $ne: true }, [`onboarding.${mark}`]: { $exists: false } });
expect(Object.keys(update.$set)).toEqual([`onboarding.${mark}`]);
expect(update.$set[`onboarding.${mark}`]).toBeInstanceOf(Date);
});

test('refuses any field outside the three surface marks, and no person means no write', () => {
markSurface('u1', 'podOpenedAt');
markSurface('u1', 'isAdmin');
markSurface('', 'inviteCardShownAt');
markSurface(undefined, 'byoOfferShownAt');
expect(mockUpdateOne).not.toHaveBeenCalled();
});

test('one write per person per mark per process; each mark is independent', () => {
markSurface('u1', 'inviteCardShownAt');
markSurface('u1', 'inviteCardShownAt');
markSurface('u1', 'inviteCardDismissedAt');
markSurface('u2', 'inviteCardShownAt');
expect(mockUpdateOne).toHaveBeenCalledTimes(3);
});

test('a failed write is retried on the next ping and never throws; reset forgets', async () => {
mockUpdateOne.mockRejectedValueOnce(new Error('mongo down'));
expect(() => markSurface('u1', 'byoOfferShownAt')).not.toThrow();
await flush();
markSurface('u1', 'byoOfferShownAt');
expect(mockUpdateOne).toHaveBeenCalledTimes(2);
resetOnboardingMarksForTest();
markSurface('u1', 'byoOfferShownAt');
expect(mockUpdateOne).toHaveBeenCalledTimes(3);
});
});

describe('the onboarding marks together', () => {
const fieldOf = ([filter]) => Object.keys(filter)[2];

Expand Down
8 changes: 8 additions & 0 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ export interface IUser extends Document {
byoCommandType?: 'mcp' | 'cli';
/** First authenticated BYO runtime request, never overwritten. */
byoFirstConnectionAttemptAt?: Date;
/** First "invite a teammate" card mounted, and first dismissed (D139). */
inviteCardShownAt?: Date;
inviteCardDismissedAt?: Date;
/** First Bring your own start mounted in a thread (D139). */
byoOfferShownAt?: Date;
// The first-touch ref code the signup arrived with (?ref=x), set once at
// account creation (utils/signupRef.ts).
signupRef?: string;
Expand Down Expand Up @@ -463,6 +468,9 @@ const userSchema = new Schema<IUser>({
byoCommandCopiedAt: { type: Date },
byoCommandType: { type: String, enum: ['mcp', 'cli'] },
byoFirstConnectionAttemptAt: { type: Date },
inviteCardShownAt: { type: Date },
inviteCardDismissedAt: { type: Date },
byoOfferShownAt: { type: Date },
signupRef: { type: String },
},
lastDigestSent: { type: Date },
Expand Down
10 changes: 9 additions & 1 deletion backend/routes/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ router.get('/public', statsPublicLimit, async (_req: unknown, res: { json: (d: u
// eslint-disable-next-line global-require
const auth = require('../middleware/auth');
// eslint-disable-next-line global-require
const { markByoCommandCopied } = require('../services/onboardingMarks');
const { markByoCommandCopied, markSurface } = require('../services/onboardingMarks');
// eslint-disable-next-line global-require
const { rateLimit } = require('express-rate-limit');
// eslint-disable-next-line global-require
Expand All @@ -89,7 +89,14 @@ const mongoose = require('mongoose');
const BYO_STEPS = new Set([
'page-viewed', 'seat-created', 'mcp-command-copied', 'cli-command-copied',
'token-copied', 'listen-confirmed', 'listen-timeout', 'hosted-offer-shown', 'hosted-offer-clicked',
'invite-card-shown', 'invite-card-dismissed', 'byo-offer-shown',
]);
// D139 surfaces: each step also sets its write-once onboarding mark.
const SURFACE_STEPS: Record<string, string> = {
'invite-card-shown': 'inviteCardShownAt',
'invite-card-dismissed': 'inviteCardDismissedAt',
'byo-offer-shown': 'byoOfferShownAt',
};

router.post('/byo-step', rateLimit({
windowMs: 60_000,
Expand All @@ -107,6 +114,7 @@ router.post('/byo-step', rateLimit({
// the copied command, which contains a runtime token.
markByoCommandCopied(req.userId, step === 'mcp-command-copied' ? 'mcp' : 'cli');
}
if (SURFACE_STEPS[step]) markSurface(req.userId, SURFACE_STEPS[step]);
try {
await mongoose.connection.db.collection('byo_telemetry').insertOne({
userId: req.userId,
Expand Down
33 changes: 33 additions & 0 deletions backend/services/onboardingMarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
* read from the page's own load request (GET /api/hosted/availability, which
* nothing else calls). It tells "never looked at setup" apart from "looked and
* left".
*
* Three surface marks come from the browser through POST /api/stats/byo-step,
* as fixed step names (D139): inviteCardShownAt and inviteCardDismissedAt for
* the "invite a teammate" card, and byoOfferShownAt the first time the thread
* shows a start that links to Bring your own. "Shown" is the card or start
* mounting in the transcript, not proof it was scrolled into view.
*/
import User from '../models/User';
import { SCOUT_STARTS } from '../config/native-agents/scout';
Expand All @@ -34,6 +40,10 @@ const startPickedMarked = new Set<string>();
const byoOpenedMarked = new Set<string>();
const byoCommandCopiedMarked = new Set<string>();
const byoConnectionAttemptMarked = new Set<string>();
const surfaceMarked = new Map<SurfaceMark, Set<string>>();

export type SurfaceMark = 'inviteCardShownAt' | 'inviteCardDismissedAt' | 'byoOfferShownAt';
export const SURFACE_MARKS: readonly SurfaceMark[] = ['inviteCardShownAt', 'inviteCardDismissedAt', 'byoOfferShownAt'];

export function markPodOpened(userId: unknown, pod: { createdBy?: unknown } | null | undefined): void {
const id = String(userId || '');
Expand Down Expand Up @@ -180,11 +190,34 @@ export function markByoConnectionAttempt(
}
}

/** Record the first time a person met one of the D139 surfaces. */
export function markSurface(userId: unknown, mark: SurfaceMark): void {
const id = String(userId || '');
if (!id || !SURFACE_MARKS.includes(mark)) return;
const marked = surfaceMarked.get(mark) || new Set<string>();
surfaceMarked.set(mark, marked);
if (marked.has(id)) return;
marked.add(id);
const field = `onboarding.${mark}`;
try {
User.updateOne(
{ _id: id, isBot: { $ne: true }, [field]: { $exists: false } },
{ $set: { [field]: new Date() } },
).catch(() => {
marked.delete(id);
});
} catch {
// Telemetry fails open: it must never break the ping that carries it.
marked.delete(id);
}
}

/** Test seam: forget which people this process has marked. */
export function resetOnboardingMarksForTest(): void {
podOpenedMarked.clear();
startPickedMarked.clear();
byoOpenedMarked.clear();
byoCommandCopiedMarked.clear();
byoConnectionAttemptMarked.clear();
surfaceMarked.clear();
}
25 changes: 25 additions & 0 deletions frontend/src/v2/__tests__/V2BringTeammate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ const mockWrite = jest.fn();
const mockApi = { get: mockGet, post: mockPost };
jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi }));
jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
const mockRecord = jest.fn();
const mockRecordOnce = jest.fn();
jest.mock('../utils/byoTelemetry', () => ({
recordByoStep: (...args: unknown[]) => mockRecord(...args),
recordByoStepOnce: (...args: unknown[]) => mockRecordOnce(...args),
}));
const token = 'a'.repeat(32);
const props = { podId: 'pod', podName: 'Work', userId: 'person', source: 'solo-pod' as const };
const press = () => fireEvent.click(screen.getByRole('button', { name: 'teammate.act' }));
Expand Down Expand Up @@ -120,3 +126,22 @@ test('a retry shares its read across visibility changes and retains the new link
expect(mockWrite).toHaveBeenCalledTimes(1);
expect(screen.getByRole('textbox')).toHaveValue(`${window.location.origin}/v2/invite/${token}`);
});

test('D139 marks: showing the card pings invite-card-shown; dismissing pings invite-card-dismissed', () => {
const view = render(<V2BringTeammate {...props} enabled={false} />);
expect(mockRecordOnce).not.toHaveBeenCalled();
view.rerender(<V2BringTeammate {...props} enabled />);
expect(mockRecordOnce).toHaveBeenCalledWith(mockApi, 'invite-card-shown', 'person');
expect(mockRecord).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'teammate.dismiss' }));
expect(mockRecord).toHaveBeenCalledWith(mockApi, 'invite-card-dismissed');
});

test('D139 marks: a card dismissed earlier never pings shown', () => {
const view = render(<V2BringTeammate {...props} />);
fireEvent.click(screen.getByRole('button', { name: 'teammate.dismiss' }));
view.unmount();
mockRecordOnce.mockClear();
render(<V2BringTeammate {...props} />);
expect(mockRecordOnce).not.toHaveBeenCalled();
});
42 changes: 42 additions & 0 deletions frontend/src/v2/__tests__/V2ThreadStarts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@
// can take the offer away before it is seen.
import React, { useState } from 'react';
import {
cleanup,
fireEvent, render, screen, waitFor, within,
} from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import axios from 'axios';
import V2Thread from '../components/V2Thread';
import { resetByoStepsForTest } from '../utils/byoTelemetry';
import { AuthContext } from '../../context/AuthContext';

jest.mock('../../context/SocketContext', () => ({
Expand Down Expand Up @@ -242,6 +245,45 @@ describe('V2Thread — the connect start links Bring your own', () => {
expect(screen.queryByRole('button', { name: OPEN_BYO })).not.toBeInTheDocument();
});

test('the link pings byo-offer-shown once; a thread without it does not', async () => {
resetByoStepsForTest();
const pings = () => axios.post.mock.calls.filter(([url, body]) => String(url).endsWith('/api/stats/byo-step')
&& body?.step === 'byo-offer-shown').length;
axios.post.mockClear();
renderThread([welcome]);
expect(pings()).toBe(0);
cleanup();
renderThread([welcome, message('mine', 'u1', 'maya', CONNECT, 4)]);
await screen.findByRole('button', { name: OPEN_BYO });
expect(pings()).toBe(1);
});

test('a matching reply folded into a thread card renders no link and records no offer', () => {
resetByoStepsForTest();
axios.post.mockClear();
renderThread([
welcome,
message('root', 'u1', 'maya', 'Where do I start?', 4),
message('reply', 'u1', 'maya', CONNECT, 5, { thread_root_id: 'root' }),
]);
expect(screen.queryByRole('button', { name: OPEN_BYO })).not.toBeInTheDocument();
expect(axios.post.mock.calls.filter(([url, body]) => String(url).endsWith('/api/stats/byo-step')
&& body?.step === 'byo-offer-shown')).toHaveLength(0);
});

test('a matching thread root renders the link and records the offer', async () => {
resetByoStepsForTest();
axios.post.mockClear();
renderThread([
welcome,
message('root', 'u1', 'maya', CONNECT, 4),
message('reply', 'scout-1', 'scout', 'Which one do you use?', 5, { thread_root_id: 'root', user: { username: 'scout', isBot: true } }),
]);
await screen.findByRole('button', { name: OPEN_BYO });
await waitFor(() => expect(axios.post.mock.calls.filter(([url, body]) => String(url).endsWith('/api/stats/byo-step')
&& body?.step === 'byo-offer-shown')).toHaveLength(1));
});

test('a message from Scout with that text gets no link', () => {
renderThread([message('echo', 'scout-1', 'scout', CONNECT, 4, { user: { username: 'scout', isBot: true } })]);
expect(screen.queryByRole('button', { name: OPEN_BYO })).not.toBeInTheDocument();
Expand Down
Loading
Loading