Files
nexu-io--open-design/apps/web/tests/onboarding-first-loop.test.ts
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08: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();
});
});