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
34 changes: 34 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,8 @@ const request = require('supertest');
const express = require('express');

const mockMarkByoCommandCopied = jest.fn();
const mockMarkByoCopyType = jest.fn();
const mockMarkByoSetup = jest.fn();
const mockMarkSurface = jest.fn();
const mockInsertOne = jest.fn().mockResolvedValue({ acknowledged: true });
const mockPassThrough = () => (_req, _res, next) => next();
Expand All @@ -19,6 +21,8 @@ jest.mock('../../../middleware/auth', () => (req, _res, next) => {
});
jest.mock('../../../services/onboardingMarks', () => ({
markByoCommandCopied: (...args) => mockMarkByoCommandCopied(...args),
markByoCopyType: (...args) => mockMarkByoCopyType(...args),
markByoSetup: (...args) => mockMarkByoSetup(...args),
markSurface: (...args) => mockMarkSurface(...args),
}));
jest.mock('express-rate-limit', () => ({
Expand Down Expand Up @@ -94,6 +98,29 @@ describe('POST /api/stats/byo-step', () => {
expect(mockInsertOne).toHaveBeenCalledWith({ userId: 'human-1', step, at: expect.any(Date) });
});

it('a copy step passes the copy type and the setup the page showed to the marks', async () => {
const res = await request(app).post('/api/stats/byo-step')
.send({ step: 'mcp-command-copied', copyType: 'mcp-claude', setupOs: 'mac', listenerAvailable: true });

expect(res.status).toBe(200);
expect(mockMarkByoCopyType).toHaveBeenCalledWith('human-1', 'mcp-claude');
expect(mockMarkByoSetup).toHaveBeenCalledWith('human-1', { setupOs: 'mac', listenerAvailable: true });
// The event row is unchanged: the copy type and setup stay out of telemetry.
expect(mockInsertOne).toHaveBeenCalledWith(expect.objectContaining({
step: 'mcp-command-copied',
commandType: 'mcp',
}));
expect(JSON.stringify(mockInsertOne.mock.calls[0][0])).not.toContain('mcp-claude');
});

it('a non-copy step records the setup but never a copy type', async () => {
await request(app).post('/api/stats/byo-step')
.send({ step: 'seat-created', copyType: 'token', setupOs: 'windows', listenerAvailable: false });

expect(mockMarkByoCopyType).not.toHaveBeenCalled();
expect(mockMarkByoSetup).toHaveBeenCalledWith('human-1', { setupOs: 'windows', listenerAvailable: false });
});

it('other steps set no surface mark', async () => {
await request(app).post('/api/stats/byo-step').send({ step: 'page-viewed' });
expect(mockMarkSurface).not.toHaveBeenCalled();
Expand All @@ -106,6 +133,13 @@ describe('POST /api/stats/byo-step', () => {
expect(mockInsertOne).not.toHaveBeenCalled();
});

it('a copy type that does not match its step writes no per-type mark', async () => {
await request(app).post('/api/stats/byo-step').send({ step: 'token-copied', copyType: 'listen' });
await request(app).post('/api/stats/byo-step').send({ step: 'cli-command-copied', copyType: 'mcp-claude' });

expect(mockMarkByoCopyType).not.toHaveBeenCalled();
});

it('rejects unknown steps without writing a mark or telemetry event', async () => {
const res = await request(app)
.post('/api/stats/byo-step')
Expand Down
50 changes: 50 additions & 0 deletions backend/__tests__/unit/services/onboardingMarks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
markPodOpened, markStartPicked, markByoOpened, markByoCommandCopied,
markByoConnectionAttempt, markWelcomeHostedStart, markSurface, markByoDaemonRegistered,
resetOnboardingMarksForTest,
markByoCopyType, markByoSetup,
} = require('../../../services/onboardingMarks');

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

describe('markByoCopyType and markByoSetup (TASK-714)', () => {
test('each command type gets its own write-once mark, so a later listen copy after MCP leaves a trace', () => {
markByoCopyType('u1', 'mcp-claude');
markByoCopyType('u1', 'listen');
expect(mockUpdateOne).toHaveBeenCalledWith(
{ _id: 'u1', isBot: { $ne: true }, 'onboarding.byoMcpClaudeCopiedAt': { $exists: false } },
{ $set: { 'onboarding.byoMcpClaudeCopiedAt': expect.any(Date) } },
);
expect(mockUpdateOne).toHaveBeenCalledWith(
{ _id: 'u1', isBot: { $ne: true }, 'onboarding.byoListenCopiedAt': { $exists: false } },
{ $set: { 'onboarding.byoListenCopiedAt': expect.any(Date) } },
);
});

test('each type is written once per person, however often it is copied', () => {
markByoCopyType('u1', 'daemon');
markByoCopyType('u1', 'daemon');
markByoCopyType('u1', 'daemon');
expect(mockUpdateOne).toHaveBeenCalledTimes(1);
});

test('refuses an unknown copy type, so the page cannot write an arbitrary field', () => {
markByoCopyType('u1', 'constructor');
markByoCopyType('u1', 'listen; drop');
markByoCopyType('u1', undefined);
markByoCopyType('', 'token');
expect(mockUpdateOne).not.toHaveBeenCalled();
});

test('writes the setup OS and listener eligibility once, and nothing else', () => {
markByoSetup('u1', { setupOs: 'mac', listenerAvailable: true });
markByoSetup('u1', { setupOs: 'windows', listenerAvailable: false });
expect(mockUpdateOne).toHaveBeenCalledTimes(1);
expect(mockUpdateOne).toHaveBeenCalledWith(
{ _id: 'u1', isBot: { $ne: true }, 'onboarding.byoListenerEligible': { $exists: false } },
{ $set: { 'onboarding.byoSetupOs': 'mac', 'onboarding.byoListenerEligible': true } },
);
expect(JSON.stringify(mockUpdateOne.mock.calls)).not.toMatch(/token|command|pod/i);
});

test('a missing or malformed setup writes nothing', () => {
markByoSetup('u1', { setupOs: 'mac' });
markByoSetup('u1', { setupOs: 'plan9', listenerAvailable: true });
markByoSetup('u1', { setupOs: 'linux', listenerAvailable: 'yes' });
markByoSetup('u1', undefined);
expect(mockUpdateOne).not.toHaveBeenCalled();
});
});

describe('markByoDaemonRegistered (TASK-661)', () => {
test('sets byoDaemonRegisteredAt write-once in the filter, never for bots', () => {
markByoDaemonRegistered('u1');
Expand Down
16 changes: 16 additions & 0 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ export interface IUser extends Document {
/** First copy of each setup command, kept per kind (TASK-721). */
byoMcpCopiedAt?: Date;
byoCliCopiedAt?: Date;
/** First copy of each command type, each its own write-once mark (TASK-714). */
byoListenCopiedAt?: Date;
byoMcpClaudeCopiedAt?: Date;
byoMcpCursorCopiedAt?: Date;
byoDaemonCopiedAt?: Date;
byoTokenCopiedAt?: Date;
/** The OS the result page set up for, and whether the listener could run there. */
byoSetupOs?: 'mac' | 'linux' | 'windows' | 'other';
byoListenerEligible?: boolean;
/** First authenticated BYO runtime request, never overwritten. */
byoFirstConnectionAttemptAt?: Date;
/** First successful `commonly daemon register`, never overwritten (TASK-661). */
Expand Down Expand Up @@ -481,6 +490,13 @@ const userSchema = new Schema<IUser>({
byoCommandType: { type: String, enum: ['mcp', 'cli'] },
byoMcpCopiedAt: { type: Date },
byoCliCopiedAt: { type: Date },
byoListenCopiedAt: { type: Date },
byoMcpClaudeCopiedAt: { type: Date },
byoMcpCursorCopiedAt: { type: Date },
byoDaemonCopiedAt: { type: Date },
byoTokenCopiedAt: { type: Date },
byoSetupOs: { type: String, enum: ['mac', 'linux', 'windows', 'other'] },
byoListenerEligible: { type: Boolean },
byoFirstConnectionAttemptAt: { type: Date },
byoDaemonRegisteredAt: { type: Date },
inviteCardShownAt: { type: Date },
Expand Down
17 changes: 16 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, markSurface } = require('../services/onboardingMarks');
const { markByoCommandCopied, markByoCopyType, markByoSetup, markSurface } = require('../services/onboardingMarks');
// eslint-disable-next-line global-require
const { rateLimit } = require('express-rate-limit');
// eslint-disable-next-line global-require
Expand All @@ -91,6 +91,14 @@ const BYO_STEPS = new Set([
'token-copied', 'listen-confirmed', 'listen-timeout', 'hosted-offer-shown', 'hosted-offer-clicked',
'invite-card-shown', 'invite-card-dismissed', 'byo-offer-shown',
]);
// Steps that copy a setup command, and the copy types each may carry. A step
// and its type must agree, so "token-copied" can never set the listen mark.
const COPY_TYPES_BY_STEP: Record<string, string[]> = {
'mcp-command-copied': ['mcp-claude', 'mcp-cursor'],
'cli-command-copied': ['listen'],
'daemon-command-copied': ['daemon'],
'token-copied': ['token'],
};
// Onboarding surfaces: each step also sets its write-once onboarding mark.
const SURFACE_STEPS: Record<string, string> = {
'invite-card-shown': 'inviteCardShownAt',
Expand All @@ -114,6 +122,13 @@ router.post('/byo-step', rateLimit({
// the copied command, which contains a runtime token.
markByoCommandCopied(req.userId, step === 'mcp-command-copied' ? 'mcp' : 'cli');
}
const allowedCopyTypes = COPY_TYPES_BY_STEP[step];
if (allowedCopyTypes && allowedCopyTypes.includes(req.body?.copyType)) {
markByoCopyType(req.userId, req.body.copyType);
}
// The setup the result page showed. The service validates both fields and
// writes nothing for a missing or malformed value.
markByoSetup(req.userId, { setupOs: req.body?.setupOs, listenerAvailable: req.body?.listenerAvailable });
if (SURFACE_STEPS[step]) markSurface(req.userId, SURFACE_STEPS[step]);
await recordByoEvent(req.userId, step as any, {
...(step === 'mcp-command-copied' ? { commandType: 'mcp' } : {}),
Expand Down
69 changes: 69 additions & 0 deletions backend/services/onboardingMarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,73 @@ export function markByoCommandCopied(userId: unknown, commandType: 'mcp' | 'cli'
}
}

/**
* The first copy of each setup command, by its own type (TASK-714).
*
* `listen` is the listener command; `mcp-claude` and `mcp-cursor` are the two
* MCP snippets; `daemon` and `token` are the daemon and token copies. Each
* gets its own write-once field, so a later listen copy after an MCP copy
* leaves a trace. The existing first-copy marks above are unchanged.
*/
export const BYO_COPY_TYPES = ['listen', 'mcp-claude', 'mcp-cursor', 'daemon', 'token'] as const;
export type ByoCopyType = typeof BYO_COPY_TYPES[number];
const BYO_COPY_FIELD: Record<ByoCopyType, string> = {
listen: 'onboarding.byoListenCopiedAt',
'mcp-claude': 'onboarding.byoMcpClaudeCopiedAt',
'mcp-cursor': 'onboarding.byoMcpCursorCopiedAt',
daemon: 'onboarding.byoDaemonCopiedAt',
token: 'onboarding.byoTokenCopiedAt',
};
const byoCopyTypeMarked = new Set<string>();

export function markByoCopyType(userId: unknown, copyType: unknown): void {
const id = String(userId || '');
if (!id || !(BYO_COPY_TYPES as readonly unknown[]).includes(copyType)) return;
const type = copyType as ByoCopyType;
const key = `${id}:${type}`;
if (byoCopyTypeMarked.has(key)) return;
byoCopyTypeMarked.add(key);
const field = BYO_COPY_FIELD[type];
try {
User.updateOne(
{ _id: id, isBot: { $ne: true }, [field]: { $exists: false } },
{ $set: { [field]: new Date() } },
).catch(() => {
byoCopyTypeMarked.delete(key);
});
} catch {
// Telemetry fails open: it must never break the copy flow.
byoCopyTypeMarked.delete(key);
}
}

/**
* The OS the result page set up for and whether its listener could run there,
* written once per person (TASK-714). Booleans and the fixed OS enum only: no
* command, token, or pod is stored. A missing or malformed value writes nothing.
*/
const byoSetupMarked = new Set<string>();

type ByoSetupInput = { setupOs?: unknown; listenerAvailable?: unknown } | null | undefined;
export function markByoSetup(userId: unknown, setup: ByoSetupInput): void {
const id = String(userId || '');
const setupOs = parseByoOpenedOs(setup?.setupOs);
const listenerEligible = setup?.listenerAvailable;
if (!id || !setupOs || typeof listenerEligible !== 'boolean' || byoSetupMarked.has(id)) return;
byoSetupMarked.add(id);
try {
User.updateOne(
{ _id: id, isBot: { $ne: true }, 'onboarding.byoListenerEligible': { $exists: false } },
{ $set: { 'onboarding.byoSetupOs': setupOs, 'onboarding.byoListenerEligible': listenerEligible } },
).catch(() => {
byoSetupMarked.delete(id);
});
} catch {
// Telemetry fails open: it must never break the copy flow.
byoSetupMarked.delete(id);
}
}

/**
* Record the first authenticated request from a BYO/local runtime.
*
Expand Down Expand Up @@ -287,6 +354,8 @@ export function resetOnboardingMarksForTest(): void {
byoOpenedMarked.clear();
byoCommandCopiedMarked.clear();
byoCommandKindMarked.clear();
byoCopyTypeMarked.clear();
byoSetupMarked.clear();
byoConnectionAttemptMarked.clear();
daemonRegisteredMarked.clear();
surfaceMarked.clear();
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/v2/__tests__/V2AgentBYOCopyTelemetry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,57 @@ test('a failed daemon copy posts no copy step', async () => {

expect(stepsPosted()).not.toContain('daemon-command-copied');
});

// TASK-714: the copy says which command it was and what the page showed, so
// the server can tell "the listener was possible" from "it was not".
const withUserAgent = (ua: string) => {
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: ua });
};
const MAC_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36';
const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36';

// Issue a seat so the result page renders (the same path the listen-snippet
// suite drives), then return the page's own setup telemetry.
const issueSeatAndReadSetup = async () => {
axios.post
.mockResolvedValueOnce({ data: { ok: true } }) // /api/registry/install
.mockResolvedValueOnce({ data: { token: 'cm_agent_test_token' } }); // runtime-tokens
renderPage();
await waitFor(() => expect(screen.getByText('Workspace (chat)')).toBeInTheDocument());
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);
return recordByoStep.mock.calls.find(([, step]) => step === 'seat-created');
};

test.each([
['mac', MAC_UA, true],
['windows', WINDOWS_UA, false],
])('the %s result page records the setup it shows (listener %s)', async (os, ua, listenerAvailable) => {
const original = window.navigator.userAgent;
withUserAgent(ua);
try {
const call = await issueSeatAndReadSetup();
expect(call?.[2]).toEqual({ setupOs: os, listenerAvailable });
} finally {
withUserAgent(original);
}
});

test('a Mac daemon copy posts its command type with the setup the page showed', async () => {
const original = window.navigator.userAgent;
withUserAgent(MAC_UA);
try {
renderPage();
const block = await screen.findByTestId('byo-add-computer');
await act(async () => {
fireEvent.click(block.querySelector('button.v2-byo__copy'));
});

const call = recordByoStep.mock.calls.find(([, step]) => step === 'daemon-command-copied');
expect(call?.[2]).toEqual({ setupOs: 'mac', listenerAvailable: true, copyType: 'daemon' });
expect(JSON.stringify(call)).not.toMatch(/commonly daemon|cm_agent/);
} finally {
withUserAgent(original);
}
});
4 changes: 2 additions & 2 deletions frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ test('exit intent offers immediately; manual copy dismisses permanently', async
test('button copy suppresses the offer beyond the Copied label', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) } });
await issue(); await act(async () => fireEvent.click(screen.getByTestId('byo-listen').querySelector('button.v2-byo__copy'))); await tick(60000); expect(offer()).not.toBeInTheDocument();
expect(axios.post).toHaveBeenCalledWith('/api/stats/byo-step', { step: 'cli-command-copied' }, expect.anything());
expect(axios.post).toHaveBeenCalledWith('/api/stats/byo-step', expect.objectContaining({ step: 'cli-command-copied', copyType: 'listen' }), expect.anything());
});
test('hosting errors preserve the command and a retryable offer', async () => {
await issue(); axios.post.mockImplementation((url) => url === '/api/registry/install' ? Promise.reject({ response: { data: { code: 'hosted_cap_reached', cap: 1 } } }) : Promise.resolve({ data: {} }));
Expand All @@ -102,7 +102,7 @@ test('page entry is recorded once under StrictMode; command rendering is separat
render(<StrictMode><AuthContext.Provider value={authValue}><MemoryRouter><V2AgentBYO /></MemoryRouter></AuthContext.Provider></StrictMode>);
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: 'hosted-offer-shown' }, { 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', setupOs: 'mac', listenerAvailable: true }]);
});
test('a failed clipboard write leaves the fallback available', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockRejectedValue(new Error('Denied')) } });
Expand Down
Loading
Loading