Files
open-design/apps/web/tests/onboarding-first-loop.test.ts
4b9072f01b feat(web): onboarding PR-C — first-loop completion funnel (+ stage dims) (#5160)
* feat(web): first-loop ledger + onboarding_completed funnel event

Tracks the first-generation loop of a recommendation-started project
(prompt_sent -> generated -> artifact_viewed -> delivered) in a session
ledger keyed by the created project id, and fires onboarding_completed
exactly once, when the loop closes with a delivery (share option picked /
export succeeded) IN THAT SAME project, carrying every step reached in
order (spec S8.3 loop, S11.1).

Project-scoped so a delivery in an unrelated later project can never
close another project's loop; the ledger is dropped on completion so
post-completion activity can't re-open or grow it. Delivery taps live in
FileViewer's share/export path (which knows the project id); the earlier
steps ride the existing ProjectView funnel points. Also adds the optional
studio_stage / feature_id dims to the studio hint events (spec S11.1)
ahead of the stage-layer and S8.7 feature-hint work, which stay blocked
on the in-flight NextStepActions branch.

Rebased onto main after #5111 merged; adapted to main's project-keyed
onboarding-entry guards and the split contracts analytics/events barrel.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): fire onboarding delivered only on export/share success

Move the first-loop `delivered` tap from the share-option click to the
export/share success callback in all three export flows (programmatic,
template, image). Closing the onboarding loop now means the artifact was
actually delivered, not merely that the user opened an export option.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 10:48:52 +00:00

121 lines
4.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { beginFirstLoop, recordFirstLoopStep } from '../src/onboarding/first-loop';
function createStorageStub() {
const store = new Map<string, string>();
return {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => { store.set(key, value); },
removeItem: (key: string) => { store.delete(key); },
clear: () => { store.clear(); },
key: (i: number) => Array.from(store.keys())[i] ?? null,
get length() { return store.size; },
};
}
const ENTRY = {
source: 'home_recommendation' as const,
productType: 'marketing' as const,
recommendationId: 'marketing_landing',
};
const PROJECT = 'proj-a';
describe('first-loop ledger (project-scoped)', () => {
beforeEach(() => {
(globalThis as unknown as { window: unknown }).window = {
sessionStorage: createStorageStub(),
};
});
afterEach(() => {
delete (globalThis as unknown as { window?: unknown }).window;
});
it('fires onboarding_completed once, on delivery, with the steps in order', () => {
const track = vi.fn();
beginFirstLoop(PROJECT, ENTRY);
recordFirstLoopStep(track, 'prompt_sent', PROJECT);
recordFirstLoopStep(track, 'generated', PROJECT);
recordFirstLoopStep(track, 'artifact_viewed', PROJECT);
expect(track).not.toHaveBeenCalled();
recordFirstLoopStep(track, 'delivered', PROJECT);
expect(track).toHaveBeenCalledTimes(1);
expect(track).toHaveBeenCalledWith('onboarding_completed', {
entry_source: 'home_recommendation',
product_type: 'marketing',
recommendation_id: 'marketing_landing',
completed_steps: ['prompt_sent', 'generated', 'artifact_viewed', 'delivered'],
});
// A second delivery in the same project does not re-fire.
recordFirstLoopStep(track, 'delivered', PROJECT);
expect(track).toHaveBeenCalledTimes(1);
});
it('dedupes repeated steps but keeps first-reached order', () => {
const track = vi.fn();
beginFirstLoop(PROJECT, ENTRY);
recordFirstLoopStep(track, 'generated', PROJECT);
recordFirstLoopStep(track, 'prompt_sent', PROJECT);
recordFirstLoopStep(track, 'generated', PROJECT);
recordFirstLoopStep(track, 'delivered', PROJECT);
expect(track.mock.calls[0]?.[1]).toMatchObject({
completed_steps: ['generated', 'prompt_sent', 'delivered'],
});
});
it('fires with delivered-only when the loop closes without earlier steps', () => {
const track = vi.fn();
beginFirstLoop(PROJECT, ENTRY);
recordFirstLoopStep(track, 'delivered', PROJECT);
expect(track).toHaveBeenCalledTimes(1);
expect(track.mock.calls[0]?.[1]).toMatchObject({
completed_steps: ['delivered'],
});
});
it('is a no-op when the project has no active first loop (ordinary projects)', () => {
const track = vi.fn();
recordFirstLoopStep(track, 'delivered', PROJECT);
expect(track).not.toHaveBeenCalled();
});
it('does not let another project\'s delivery close this loop', () => {
const track = vi.fn();
beginFirstLoop(PROJECT, ENTRY);
recordFirstLoopStep(track, 'prompt_sent', PROJECT);
// A delivery in an unrelated project (no ledger) is a silent no-op and
// must not fire completion for PROJECT.
recordFirstLoopStep(track, 'delivered', 'proj-b');
expect(track).not.toHaveBeenCalled();
// PROJECT's own delivery still closes its own loop.
recordFirstLoopStep(track, 'delivered', PROJECT);
expect(track).toHaveBeenCalledTimes(1);
expect(track.mock.calls[0]?.[1]).toMatchObject({
recommendation_id: 'marketing_landing',
completed_steps: ['prompt_sent', 'delivered'],
});
});
it('beginFirstLoop pins the first entry for a project and ignores later ones', () => {
const track = vi.fn();
beginFirstLoop(PROJECT, ENTRY);
beginFirstLoop(PROJECT, { ...ENTRY, recommendationId: 'other_starter' });
recordFirstLoopStep(track, 'delivered', PROJECT);
expect(track.mock.calls[0]?.[1]).toMatchObject({
recommendation_id: 'marketing_landing',
});
});
it('never throws when storage is denied', () => {
(globalThis as unknown as { window: unknown }).window = {
get sessionStorage(): Storage {
throw new Error('denied');
},
};
const track = vi.fn();
expect(() => beginFirstLoop(PROJECT, ENTRY)).not.toThrow();
expect(() => recordFirstLoopStep(track, 'delivered', PROJECT)).not.toThrow();
expect(track).not.toHaveBeenCalled();
});
});