onboardme-sdk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE-v2.md +225 -0
- package/dist/sdk.iife.js +348 -0
- package/package.json +22 -0
- package/src/__tests__/day1.test.ts +37 -0
- package/src/__tests__/day2.test.ts +447 -0
- package/src/__tests__/day3.test.ts +110 -0
- package/src/__tests__/day4.test.ts +115 -0
- package/src/__tests__/day5.test.ts +102 -0
- package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
- package/src/__tests__/snapshot-sender.test.ts +111 -0
- package/src/__tests__/v2-integration.test.ts +305 -0
- package/src/__tests__/v2-positioner.test.ts +115 -0
- package/src/__tests__/v2-renderer.test.ts +189 -0
- package/src/__tests__/v2-types.test.ts +74 -0
- package/src/__tests__/week2-day1.test.ts +62 -0
- package/src/__tests__/week2-day2.test.ts +128 -0
- package/src/__tests__/week2-day3.test.ts +128 -0
- package/src/__tests__/week2-day4.test.ts +177 -0
- package/src/__tests__/week2-day5.test.ts +294 -0
- package/src/__tests__/week3-day1.test.ts +169 -0
- package/src/__tests__/week3-day2.test.ts +267 -0
- package/src/__tests__/week3-day3.test.ts +213 -0
- package/src/__tests__/week3-day4.test.ts +213 -0
- package/src/__tests__/week3-day5.test.ts +350 -0
- package/src/__tests__/week4-day1.test.ts +277 -0
- package/src/__tests__/week4-day2.test.ts +227 -0
- package/src/__tests__/week4-day3.test.ts +323 -0
- package/src/__tests__/week4-day4.test.ts +210 -0
- package/src/__tests__/week4-day5.test.ts +503 -0
- package/src/__tests__/week5-day1.test.ts +152 -0
- package/src/__tests__/week5-day2.test.ts +222 -0
- package/src/__tests__/week5-day3.test.ts +297 -0
- package/src/__tests__/week5-day4.test.ts +306 -0
- package/src/__tests__/week5-day5.test.ts +345 -0
- package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
- package/src/auto-generate/context-collector.ts +47 -0
- package/src/auto-generate/flow-generator-client.ts +97 -0
- package/src/browser.ts +5 -0
- package/src/components/celebration.ts +44 -0
- package/src/components/checklist-css.ts +159 -0
- package/src/components/checklist.ts +295 -0
- package/src/components/modal-css.ts +96 -0
- package/src/components/modal.ts +171 -0
- package/src/components/shadow-host.ts +30 -0
- package/src/core/api-client.ts +39 -0
- package/src/core/api-flows.ts +204 -0
- package/src/core/config.ts +37 -0
- package/src/core/event-batcher.ts +169 -0
- package/src/core/sdk.ts +301 -0
- package/src/detection/user-detection.ts +55 -0
- package/src/index.ts +95 -0
- package/src/snapshot/dom-collector.ts +193 -0
- package/src/snapshot/sender.ts +105 -0
- package/src/storage/event-listener.ts +59 -0
- package/src/storage/progress-tracker.ts +78 -0
- package/src/styles/checklist-css.ts +159 -0
- package/src/styles/checklist.css +166 -0
- package/src/styles/modal-css.ts +96 -0
- package/src/styles/modal.css +102 -0
- package/src/utils/dom.ts +49 -0
- package/src/utils/fingerprint.ts +20 -0
- package/src/utils/logger.ts +17 -0
- package/src/v2/positioner.ts +105 -0
- package/src/v2/renderer.ts +287 -0
- package/src/v2/styles.ts +89 -0
- package/src/v2/types.ts +53 -0
- package/tsconfig.json +11 -0
- package/vite.config.ts +28 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Day 5 Tests — SDK Orchestrator + Week 1 Edge Cases
|
|
3
|
+
*
|
|
4
|
+
* Tests for core/sdk.ts (runSDK) and the full init() flow.
|
|
5
|
+
* These are the integration-level checks from the Week 1 completion checklist.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { runSDK, _resetSDK } from '../core/sdk.js';
|
|
10
|
+
import OnboardMe, { _resetIndex } from '../index.js';
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
localStorage.clear();
|
|
14
|
+
sessionStorage.clear();
|
|
15
|
+
document.body.innerHTML = '';
|
|
16
|
+
_resetSDK();
|
|
17
|
+
_resetIndex();
|
|
18
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
19
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// runSDK — orchestrator
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe('Day 5 — runSDK orchestrator', () => {
|
|
31
|
+
it('returns null and does not throw when config is missing productId', () => {
|
|
32
|
+
expect(() => runSDK({ flows: [] })).not.toThrow();
|
|
33
|
+
expect(runSDK({ flows: [] })).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns null and does not throw when config is null', () => {
|
|
37
|
+
expect(() => runSDK(null)).not.toThrow();
|
|
38
|
+
expect(runSDK(null)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns a valid SDKState when config is correct', () => {
|
|
42
|
+
const state = runSDK({ productId: 'my-app', flows: [] });
|
|
43
|
+
expect(state).not.toBeNull();
|
|
44
|
+
expect(state?.config.productId).toBe('my-app');
|
|
45
|
+
expect(typeof state?.anonymousId).toBe('string');
|
|
46
|
+
expect(typeof state?.showOnboarding).toBe('boolean');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('sets showOnboarding: true for a new user (no seen flag)', () => {
|
|
50
|
+
const state = runSDK({ productId: 'brand-new', flows: [] });
|
|
51
|
+
expect(state?.showOnboarding).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('sets showOnboarding: false for a returning user (seen flag present)', () => {
|
|
55
|
+
localStorage.setItem('onboardme_seen_returning-app', '1');
|
|
56
|
+
const state = runSDK({ productId: 'returning-app', flows: [] });
|
|
57
|
+
expect(state?.showOnboarding).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('empty flows does not throw — warns in debug mode instead', async () => {
|
|
61
|
+
const { setDebug } = await import('../utils/logger.js');
|
|
62
|
+
setDebug(true);
|
|
63
|
+
expect(() => runSDK({ productId: 'my-app', flows: [] })).not.toThrow();
|
|
64
|
+
expect(console.warn).toHaveBeenCalled();
|
|
65
|
+
setDebug(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns a stable anonymousId — same value on repeated calls for same product', () => {
|
|
69
|
+
const state1 = runSDK({ productId: 'stable-app', flows: [] });
|
|
70
|
+
// Reset the init guard so a second runSDK call is allowed (simulates a new page load).
|
|
71
|
+
// localStorage is NOT cleared — verifies the stored anon ID is reused.
|
|
72
|
+
_resetSDK();
|
|
73
|
+
const state2 = runSDK({ productId: 'stable-app', flows: [] });
|
|
74
|
+
expect(state1?.anonymousId).toBe(state2?.anonymousId);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// init() — full public API integration
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
describe('Day 5 — init() integration (Week 1 checklist)', () => {
|
|
83
|
+
it('calling init() with missing productId does not throw', () => {
|
|
84
|
+
expect(() => {
|
|
85
|
+
// @ts-expect-error — intentionally bad input
|
|
86
|
+
OnboardMe.init({ flows: [] });
|
|
87
|
+
}).not.toThrow();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('calling init() with empty flows does not throw', () => {
|
|
91
|
+
expect(() => {
|
|
92
|
+
OnboardMe.init({ productId: 'checklist-app', flows: [] });
|
|
93
|
+
}).not.toThrow();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('calling init() twice does not throw', () => {
|
|
97
|
+
expect(() => {
|
|
98
|
+
OnboardMe.init({ productId: 'double-init', flows: [] });
|
|
99
|
+
OnboardMe.init({ productId: 'double-init', flows: [] });
|
|
100
|
+
}).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { collectSnapshot, buildSelector } from '../snapshot/dom-collector'
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
document.body.innerHTML = ''
|
|
6
|
+
document.title = 'Test Page'
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
function setBody(html: string) {
|
|
10
|
+
document.body.innerHTML = html
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('collectSnapshot — element extraction', () => {
|
|
14
|
+
it('captures headings, buttons, links, inputs, forms, navs, images', () => {
|
|
15
|
+
setBody(`
|
|
16
|
+
<h1>Welcome</h1>
|
|
17
|
+
<h2>Get started</h2>
|
|
18
|
+
<nav aria-label="primary">
|
|
19
|
+
<a href="/dash">Dashboard</a>
|
|
20
|
+
</nav>
|
|
21
|
+
<form action="/submit" method="post">
|
|
22
|
+
<input type="email" name="email" placeholder="Email" />
|
|
23
|
+
<button type="submit">Sign up</button>
|
|
24
|
+
</form>
|
|
25
|
+
<img src="/logo.png" alt="Logo" />
|
|
26
|
+
`)
|
|
27
|
+
|
|
28
|
+
const snap = collectSnapshot()
|
|
29
|
+
|
|
30
|
+
const roles = snap.elements.map((e) => e.role).sort()
|
|
31
|
+
expect(roles).toContain('heading')
|
|
32
|
+
expect(roles).toContain('button')
|
|
33
|
+
expect(roles).toContain('link')
|
|
34
|
+
expect(roles).toContain('input')
|
|
35
|
+
expect(roles).toContain('form')
|
|
36
|
+
expect(roles).toContain('nav')
|
|
37
|
+
expect(roles).toContain('image')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('captures heading text correctly', () => {
|
|
41
|
+
setBody('<h1>Welcome to OnboardMe</h1><h2>Step 1</h2>')
|
|
42
|
+
const snap = collectSnapshot()
|
|
43
|
+
const headings = snap.elements.filter((e) => e.role === 'heading')
|
|
44
|
+
expect(headings.length).toBe(2)
|
|
45
|
+
expect(headings[0].text).toBe('Welcome to OnboardMe')
|
|
46
|
+
expect(headings[1].text).toBe('Step 1')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('extracts button attributes (type, name, aria-label, data-testid)', () => {
|
|
50
|
+
setBody(`
|
|
51
|
+
<button type="submit" name="primary-cta" aria-label="Sign up" data-testid="signup-btn">Go</button>
|
|
52
|
+
`)
|
|
53
|
+
const snap = collectSnapshot()
|
|
54
|
+
const btn = snap.elements.find((e) => e.role === 'button')
|
|
55
|
+
expect(btn?.attributes?.type).toBe('submit')
|
|
56
|
+
expect(btn?.attributes?.name).toBe('primary-cta')
|
|
57
|
+
expect(btn?.attributes?.['aria-label']).toBe('Sign up')
|
|
58
|
+
expect(btn?.attributes?.['data-testid']).toBe('signup-btn')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('extracts link href + image alt', () => {
|
|
62
|
+
setBody('<a href="/foo" aria-label="Foo">Foo</a><img src="/x.png" alt="x" />')
|
|
63
|
+
const snap = collectSnapshot()
|
|
64
|
+
const link = snap.elements.find((e) => e.role === 'link')
|
|
65
|
+
const img = snap.elements.find((e) => e.role === 'image')
|
|
66
|
+
expect(link?.attributes?.href).toBe('/foo')
|
|
67
|
+
expect(img?.attributes?.alt).toBe('x')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does NOT double-count an element that matches multiple selectors', () => {
|
|
71
|
+
setBody('<a href="/x" role="button">Click</a>')
|
|
72
|
+
const snap = collectSnapshot()
|
|
73
|
+
// The <a role="button"> should be classified as a button (earlier in
|
|
74
|
+
// the COLLECTORS list) and not also picked up as a link.
|
|
75
|
+
const matches = snap.elements.filter((e) => e.text === 'Click')
|
|
76
|
+
expect(matches.length).toBe(1)
|
|
77
|
+
expect(matches[0].role).toBe('button')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('truncates very long text', () => {
|
|
81
|
+
const long = 'word '.repeat(100)
|
|
82
|
+
setBody(`<h1>${long}</h1>`)
|
|
83
|
+
const snap = collectSnapshot()
|
|
84
|
+
const h1 = snap.elements[0]
|
|
85
|
+
expect((h1.text ?? '').length).toBeLessThanOrEqual(200)
|
|
86
|
+
expect(h1.text?.endsWith('…')).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('skips display:none and visibility:hidden elements', () => {
|
|
90
|
+
setBody(`
|
|
91
|
+
<h1 style="display:none">Hidden 1</h1>
|
|
92
|
+
<h2 style="visibility:hidden">Hidden 2</h2>
|
|
93
|
+
<h3>Visible</h3>
|
|
94
|
+
`)
|
|
95
|
+
const snap = collectSnapshot()
|
|
96
|
+
const headingTexts = snap.elements.filter((e) => e.role === 'heading').map((e) => e.text)
|
|
97
|
+
expect(headingTexts).toEqual(['Visible'])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('caps the total at 500 elements', () => {
|
|
101
|
+
let html = ''
|
|
102
|
+
for (let i = 0; i < 600; i++) html += `<h6>H${i}</h6>`
|
|
103
|
+
setBody(html)
|
|
104
|
+
const snap = collectSnapshot()
|
|
105
|
+
expect(snap.elements.length).toBeLessThanOrEqual(500)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('records pageUrl, pageTitle, collectedAt, viewport meta', () => {
|
|
109
|
+
setBody('<h1>x</h1>')
|
|
110
|
+
document.title = 'My Title'
|
|
111
|
+
const before = Date.now()
|
|
112
|
+
const snap = collectSnapshot()
|
|
113
|
+
expect(snap.pageTitle).toBe('My Title')
|
|
114
|
+
expect(snap.pageUrl).toContain('http') // jsdom default URL has http
|
|
115
|
+
expect(snap.collectedAt).toBeGreaterThanOrEqual(before)
|
|
116
|
+
expect(snap.meta?.viewport?.width).toBeGreaterThanOrEqual(0)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('buildSelector', () => {
|
|
121
|
+
it('prefers data-testid', () => {
|
|
122
|
+
setBody('<button data-testid="primary-cta" id="some-id">x</button>')
|
|
123
|
+
const btn = document.querySelector('button')!
|
|
124
|
+
expect(buildSelector(btn)).toBe('[data-testid="primary-cta"]')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('uses id when stable-looking', () => {
|
|
128
|
+
setBody('<button id="signup-btn">x</button>')
|
|
129
|
+
const btn = document.querySelector('button')!
|
|
130
|
+
expect(buildSelector(btn)).toBe('#signup-btn')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('skips auto-generated-looking ids', () => {
|
|
134
|
+
setBody('<div><button id="__react_1234567">x</button></div>')
|
|
135
|
+
const btn = document.querySelector('button')!
|
|
136
|
+
// Falls back to tag (only one button child)
|
|
137
|
+
expect(buildSelector(btn)).toBe('button')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('uses nth-of-type when multiple siblings of same tag exist', () => {
|
|
141
|
+
setBody(`
|
|
142
|
+
<div>
|
|
143
|
+
<button>A</button>
|
|
144
|
+
<button>B</button>
|
|
145
|
+
<button>C</button>
|
|
146
|
+
</div>
|
|
147
|
+
`)
|
|
148
|
+
const buttons = document.querySelectorAll('button')
|
|
149
|
+
expect(buildSelector(buttons[0])).toBe('button:nth-of-type(1)')
|
|
150
|
+
expect(buildSelector(buttons[1])).toBe('button:nth-of-type(2)')
|
|
151
|
+
expect(buildSelector(buttons[2])).toBe('button:nth-of-type(3)')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
collectAndSendSnapshot,
|
|
4
|
+
hasSentForThisPage,
|
|
5
|
+
markSentForThisPage,
|
|
6
|
+
postSnapshot,
|
|
7
|
+
} from '../snapshot/sender'
|
|
8
|
+
|
|
9
|
+
function setBody(html: string) {
|
|
10
|
+
document.body.innerHTML = html
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
document.body.innerHTML = ''
|
|
15
|
+
sessionStorage.clear()
|
|
16
|
+
vi.restoreAllMocks()
|
|
17
|
+
// Default fetch returns 201
|
|
18
|
+
vi.stubGlobal(
|
|
19
|
+
'fetch',
|
|
20
|
+
vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 201 })),
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('hasSentForThisPage / markSentForThisPage', () => {
|
|
25
|
+
it('round-trips through sessionStorage', () => {
|
|
26
|
+
expect(hasSentForThisPage('p1', '/page')).toBe(false)
|
|
27
|
+
markSentForThisPage('p1', '/page')
|
|
28
|
+
expect(hasSentForThisPage('p1', '/page')).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('keys per (productId, pageUrl)', () => {
|
|
32
|
+
markSentForThisPage('p1', '/a')
|
|
33
|
+
expect(hasSentForThisPage('p1', '/a')).toBe(true)
|
|
34
|
+
expect(hasSentForThisPage('p1', '/b')).toBe(false)
|
|
35
|
+
expect(hasSentForThisPage('p2', '/a')).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('postSnapshot', () => {
|
|
40
|
+
it('sends to <endpoint>/v1/code-sources/snapshot with x-api-key header', async () => {
|
|
41
|
+
const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 201 }))
|
|
42
|
+
vi.stubGlobal('fetch', fetchSpy)
|
|
43
|
+
|
|
44
|
+
const payload = {
|
|
45
|
+
pageUrl: 'https://x.com',
|
|
46
|
+
pageTitle: 'X',
|
|
47
|
+
collectedAt: 0,
|
|
48
|
+
elements: [{ selector: 'h1', role: 'heading' as const }],
|
|
49
|
+
}
|
|
50
|
+
const result = await postSnapshot('https://api.test', 'KEY', payload)
|
|
51
|
+
|
|
52
|
+
expect(result.ok).toBe(true)
|
|
53
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
54
|
+
const [url, init] = fetchSpy.mock.calls[0]
|
|
55
|
+
expect(url).toBe('https://api.test/v1/code-sources/snapshot')
|
|
56
|
+
expect((init as RequestInit).method).toBe('POST')
|
|
57
|
+
const headers = (init as RequestInit).headers as Record<string, string>
|
|
58
|
+
expect(headers['x-api-key']).toBe('KEY')
|
|
59
|
+
expect(headers['Content-Type']).toBe('application/json')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns ok:false on network error without throwing', async () => {
|
|
63
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')))
|
|
64
|
+
|
|
65
|
+
const result = await postSnapshot('https://api.test', 'KEY', {
|
|
66
|
+
pageUrl: 'x', pageTitle: 'x', collectedAt: 0, elements: [],
|
|
67
|
+
})
|
|
68
|
+
expect(result.ok).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('collectAndSendSnapshot', () => {
|
|
73
|
+
it('happy path: collects DOM, sends, marks session', async () => {
|
|
74
|
+
setBody('<h1>Welcome</h1><button>Go</button>')
|
|
75
|
+
const result = await collectAndSendSnapshot('prod-1', 'https://api.test', 'KEY')
|
|
76
|
+
|
|
77
|
+
expect(result.sent).toBe(true)
|
|
78
|
+
expect(hasSentForThisPage('prod-1', window.location.href)).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('skips when already sent for this page in this session', async () => {
|
|
82
|
+
setBody('<h1>Welcome</h1>')
|
|
83
|
+
markSentForThisPage('prod-1', window.location.href)
|
|
84
|
+
|
|
85
|
+
const fetchSpy = vi.fn()
|
|
86
|
+
vi.stubGlobal('fetch', fetchSpy)
|
|
87
|
+
|
|
88
|
+
const result = await collectAndSendSnapshot('prod-1', 'https://api.test', 'KEY')
|
|
89
|
+
expect(result.sent).toBe(false)
|
|
90
|
+
expect(result.skipped).toBe('already_sent')
|
|
91
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('skips when DOM has no extractable elements', async () => {
|
|
95
|
+
setBody('') // no headings/buttons/links/forms etc
|
|
96
|
+
const result = await collectAndSendSnapshot('prod-1', 'https://api.test', 'KEY')
|
|
97
|
+
expect(result.sent).toBe(false)
|
|
98
|
+
expect(result.skipped).toBe('no_elements')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('does NOT mark sent when the request fails', async () => {
|
|
102
|
+
setBody('<h1>x</h1>')
|
|
103
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('fail', { status: 500 })))
|
|
104
|
+
|
|
105
|
+
const result = await collectAndSendSnapshot('prod-1', 'https://api.test', 'KEY')
|
|
106
|
+
expect(result.sent).toBe(false)
|
|
107
|
+
expect(result.skipped).toBe('error')
|
|
108
|
+
// Critical: a failed send must allow a retry on the next page load.
|
|
109
|
+
expect(hasSentForThisPage('prod-1', window.location.href)).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-integration.test.ts — Phase F
|
|
3
|
+
*
|
|
4
|
+
* End-to-end test verifying the complete v2 flow:
|
|
5
|
+
* Flow creation → API fetch → SDK rendering → step transitions → completion
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
9
|
+
import type { V2FlowConfig } from '../v2/types'
|
|
10
|
+
import { renderV2Flow } from '../v2/renderer'
|
|
11
|
+
import { getShadowRoot } from '../components/shadow-host'
|
|
12
|
+
|
|
13
|
+
describe('v2 Integration — End-to-End Flow Rendering', () => {
|
|
14
|
+
let shadowRoot: ShadowRoot
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
shadowRoot = getShadowRoot()
|
|
18
|
+
// Clear any existing content
|
|
19
|
+
shadowRoot.innerHTML = '<style data-onboardme="styles"></style>'
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should render a complete v2 flow with multiple step types', () => {
|
|
23
|
+
const v2Flow: V2FlowConfig = {
|
|
24
|
+
steps: [
|
|
25
|
+
{
|
|
26
|
+
id: 'step-1',
|
|
27
|
+
type: 'modal',
|
|
28
|
+
title: 'Welcome',
|
|
29
|
+
description: 'Let\'s get started!',
|
|
30
|
+
action: { type: 'next' },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'step-2',
|
|
34
|
+
type: 'tooltip',
|
|
35
|
+
title: 'Find the button',
|
|
36
|
+
description: 'Click here to proceed',
|
|
37
|
+
position: { selector: 'button[data-test="proceed"]', placement: 'bottom' },
|
|
38
|
+
action: { type: 'next' },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'step-3',
|
|
42
|
+
type: 'highlight',
|
|
43
|
+
description: 'You\'ve completed the flow',
|
|
44
|
+
position: { selector: 'button[data-test="proceed"]', placement: 'bottom' },
|
|
45
|
+
action: { type: 'complete' },
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const handle = renderV2Flow(v2Flow, shadowRoot)
|
|
51
|
+
|
|
52
|
+
// Step 1: Modal should be rendered
|
|
53
|
+
expect(handle).not.toBeNull()
|
|
54
|
+
const modalTitle = shadowRoot.querySelector('.om2-modal-card h3')
|
|
55
|
+
expect(modalTitle?.textContent).toContain('Welcome')
|
|
56
|
+
|
|
57
|
+
// Modal should have action button
|
|
58
|
+
const actionBtn = shadowRoot.querySelector('.om2-btn-primary')
|
|
59
|
+
expect(actionBtn).not.toBeNull()
|
|
60
|
+
expect(actionBtn?.textContent).toMatch(/next|done/i)
|
|
61
|
+
|
|
62
|
+
// Cleanup
|
|
63
|
+
handle?.destroy()
|
|
64
|
+
expect(shadowRoot.querySelector('.om2-modal-card')).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should handle step transitions via action buttons', () => {
|
|
68
|
+
const v2Flow: V2FlowConfig = {
|
|
69
|
+
steps: [
|
|
70
|
+
{
|
|
71
|
+
id: 'step-a',
|
|
72
|
+
type: 'modal',
|
|
73
|
+
title: 'Step A',
|
|
74
|
+
description: 'First step',
|
|
75
|
+
action: { type: 'next' },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'step-b',
|
|
79
|
+
type: 'modal',
|
|
80
|
+
title: 'Step B',
|
|
81
|
+
description: 'Second step',
|
|
82
|
+
action: { type: 'complete' },
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const handle = renderV2Flow(v2Flow, shadowRoot)
|
|
88
|
+
|
|
89
|
+
// Initial state: Step A rendered
|
|
90
|
+
let title = shadowRoot.querySelector('.om2-modal-card h3')
|
|
91
|
+
expect(title?.textContent).toContain('Step A')
|
|
92
|
+
|
|
93
|
+
// Simulate clicking "Next" button
|
|
94
|
+
const actionBtn = shadowRoot.querySelector<HTMLButtonElement>(
|
|
95
|
+
'.om2-btn-primary'
|
|
96
|
+
)
|
|
97
|
+
expect(actionBtn).not.toBeNull()
|
|
98
|
+
actionBtn?.click()
|
|
99
|
+
|
|
100
|
+
// After click: Step B should be rendered
|
|
101
|
+
title = shadowRoot.querySelector('.om2-modal-card h3')
|
|
102
|
+
expect(title?.textContent).toContain('Step B')
|
|
103
|
+
|
|
104
|
+
// Click again to complete
|
|
105
|
+
const finalBtn = shadowRoot.querySelector<HTMLButtonElement>(
|
|
106
|
+
'.om2-btn-primary'
|
|
107
|
+
)
|
|
108
|
+
finalBtn?.click()
|
|
109
|
+
|
|
110
|
+
// After complete: flow should be torn down
|
|
111
|
+
expect(shadowRoot.querySelector('.om2-modal-card')).toBeNull()
|
|
112
|
+
|
|
113
|
+
// Cleanup
|
|
114
|
+
handle?.destroy()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should correctly render tooltip with target selector', () => {
|
|
118
|
+
// Create a test target element
|
|
119
|
+
const testDiv = document.createElement('div')
|
|
120
|
+
testDiv.setAttribute('data-test', 'target')
|
|
121
|
+
testDiv.textContent = 'Target Element'
|
|
122
|
+
document.body.appendChild(testDiv)
|
|
123
|
+
|
|
124
|
+
const v2Flow: V2FlowConfig = {
|
|
125
|
+
steps: [
|
|
126
|
+
{
|
|
127
|
+
id: 'tooltip-step',
|
|
128
|
+
type: 'tooltip',
|
|
129
|
+
title: 'Tip',
|
|
130
|
+
description: 'This is a tooltip',
|
|
131
|
+
position: { selector: '[data-test="target"]', placement: 'right' },
|
|
132
|
+
action: { type: 'complete' },
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const handle = renderV2Flow(v2Flow, shadowRoot)
|
|
138
|
+
|
|
139
|
+
// Tooltip should be rendered
|
|
140
|
+
const tooltip = shadowRoot.querySelector('.om2-tooltip')
|
|
141
|
+
expect(tooltip).not.toBeNull()
|
|
142
|
+
expect(tooltip?.textContent).toContain('Tip')
|
|
143
|
+
|
|
144
|
+
// Cleanup
|
|
145
|
+
handle?.destroy()
|
|
146
|
+
testDiv.remove()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should render highlight with ring', () => {
|
|
150
|
+
// Create a test target element
|
|
151
|
+
const testDiv = document.createElement('div')
|
|
152
|
+
testDiv.setAttribute('data-test', 'highlight-target')
|
|
153
|
+
testDiv.textContent = 'Highlighted Element'
|
|
154
|
+
document.body.appendChild(testDiv)
|
|
155
|
+
|
|
156
|
+
const v2Flow: V2FlowConfig = {
|
|
157
|
+
steps: [
|
|
158
|
+
{
|
|
159
|
+
id: 'highlight-step',
|
|
160
|
+
type: 'highlight',
|
|
161
|
+
description: 'This element is highlighted',
|
|
162
|
+
position: { selector: '[data-test="highlight-target"]', placement: 'bottom' },
|
|
163
|
+
action: { type: 'complete' },
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const handle = renderV2Flow(v2Flow, shadowRoot)
|
|
169
|
+
|
|
170
|
+
// Highlight should render (outline ring)
|
|
171
|
+
const highlight = shadowRoot.querySelector('.om2-highlight')
|
|
172
|
+
expect(highlight).not.toBeNull()
|
|
173
|
+
|
|
174
|
+
// Cleanup
|
|
175
|
+
handle?.destroy()
|
|
176
|
+
testDiv.remove()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('should skip flow when action is skip', () => {
|
|
180
|
+
const v2Flow: V2FlowConfig = {
|
|
181
|
+
steps: [
|
|
182
|
+
{
|
|
183
|
+
id: 'step-1',
|
|
184
|
+
type: 'modal',
|
|
185
|
+
title: 'Skippable Step',
|
|
186
|
+
description: 'You can skip this',
|
|
187
|
+
action: { type: 'skip' },
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const handle = renderV2Flow(v2Flow, shadowRoot)
|
|
193
|
+
|
|
194
|
+
// Modal should be rendered
|
|
195
|
+
expect(shadowRoot.querySelector('.om2-modal-card')).not.toBeNull()
|
|
196
|
+
|
|
197
|
+
// Click the action button (which has action: skip)
|
|
198
|
+
const actionBtn = shadowRoot.querySelector<HTMLButtonElement>(
|
|
199
|
+
'.om2-btn-primary'
|
|
200
|
+
)
|
|
201
|
+
actionBtn?.click()
|
|
202
|
+
|
|
203
|
+
// Flow should be completely torn down
|
|
204
|
+
expect(shadowRoot.querySelector('.om2-modal-card')).toBeNull()
|
|
205
|
+
|
|
206
|
+
// Cleanup
|
|
207
|
+
handle?.destroy()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('should handle re-rendering when flow is updated', () => {
|
|
211
|
+
const v2Flow: V2FlowConfig = {
|
|
212
|
+
steps: [
|
|
213
|
+
{
|
|
214
|
+
id: 'step-1',
|
|
215
|
+
type: 'modal',
|
|
216
|
+
title: 'Original Title',
|
|
217
|
+
description: 'Original content',
|
|
218
|
+
action: { type: 'complete' },
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const handle1 = renderV2Flow(v2Flow, shadowRoot)
|
|
224
|
+
expect(shadowRoot.querySelector('.om2-modal-card h3')?.textContent)
|
|
225
|
+
.toContain('Original Title')
|
|
226
|
+
|
|
227
|
+
// Destroy old render
|
|
228
|
+
handle1?.destroy()
|
|
229
|
+
|
|
230
|
+
// Render new flow with different content
|
|
231
|
+
const v2Flow2: V2FlowConfig = {
|
|
232
|
+
steps: [
|
|
233
|
+
{
|
|
234
|
+
id: 'step-1-updated',
|
|
235
|
+
type: 'modal',
|
|
236
|
+
title: 'Updated Title',
|
|
237
|
+
description: 'Updated content',
|
|
238
|
+
action: { type: 'complete' },
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const handle2 = renderV2Flow(v2Flow2, shadowRoot)
|
|
244
|
+
expect(shadowRoot.querySelector('.om2-modal-card h3')?.textContent)
|
|
245
|
+
.toContain('Updated Title')
|
|
246
|
+
|
|
247
|
+
// Cleanup
|
|
248
|
+
handle2?.destroy()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('should clean up all DOM on destroy', () => {
|
|
252
|
+
const v2Flow: V2FlowConfig = {
|
|
253
|
+
steps: [
|
|
254
|
+
{
|
|
255
|
+
id: 'step-1',
|
|
256
|
+
type: 'modal',
|
|
257
|
+
title: 'Will be destroyed',
|
|
258
|
+
description: 'All DOM will be removed',
|
|
259
|
+
action: { type: 'complete' },
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const handle = renderV2Flow(v2Flow, shadowRoot)
|
|
265
|
+
|
|
266
|
+
// Verify content is rendered
|
|
267
|
+
expect(shadowRoot.querySelector('.om2-modal-card')).not.toBeNull()
|
|
268
|
+
|
|
269
|
+
// Destroy
|
|
270
|
+
handle?.destroy()
|
|
271
|
+
|
|
272
|
+
// Verify all v2 content is removed (modal, tooltip, highlight should be gone)
|
|
273
|
+
expect(shadowRoot.querySelector('.om2-modal-card')).toBeNull()
|
|
274
|
+
expect(shadowRoot.querySelector('.om2-tooltip')).toBeNull()
|
|
275
|
+
expect(shadowRoot.querySelector('.om2-highlight')).toBeNull()
|
|
276
|
+
|
|
277
|
+
// Style tag should still exist
|
|
278
|
+
expect(shadowRoot.querySelector('style')).not.toBeNull()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('should handle empty flow gracefully', () => {
|
|
282
|
+
const emptyFlow: V2FlowConfig = {
|
|
283
|
+
steps: [],
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const handle = renderV2Flow(emptyFlow, shadowRoot)
|
|
287
|
+
|
|
288
|
+
// Should return null for empty flow
|
|
289
|
+
expect(handle).toBeNull()
|
|
290
|
+
|
|
291
|
+
// Shadow root should be unchanged (no modal, tooltip, or highlight)
|
|
292
|
+
expect(shadowRoot.querySelector('.om2-modal-card')).toBeNull()
|
|
293
|
+
expect(shadowRoot.querySelector('.om2-tooltip')).toBeNull()
|
|
294
|
+
expect(shadowRoot.querySelector('.om2-highlight')).toBeNull()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('should handle flow with no steps gracefully', () => {
|
|
298
|
+
const flowNoSteps = {} as V2FlowConfig
|
|
299
|
+
|
|
300
|
+
const handle = renderV2Flow(flowNoSteps, shadowRoot)
|
|
301
|
+
|
|
302
|
+
// Should return null for flow with no steps
|
|
303
|
+
expect(handle).toBeNull()
|
|
304
|
+
})
|
|
305
|
+
})
|