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.
Files changed (69) hide show
  1. package/ARCHITECTURE-v2.md +225 -0
  2. package/dist/sdk.iife.js +348 -0
  3. package/package.json +22 -0
  4. package/src/__tests__/day1.test.ts +37 -0
  5. package/src/__tests__/day2.test.ts +447 -0
  6. package/src/__tests__/day3.test.ts +110 -0
  7. package/src/__tests__/day4.test.ts +115 -0
  8. package/src/__tests__/day5.test.ts +102 -0
  9. package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
  10. package/src/__tests__/snapshot-sender.test.ts +111 -0
  11. package/src/__tests__/v2-integration.test.ts +305 -0
  12. package/src/__tests__/v2-positioner.test.ts +115 -0
  13. package/src/__tests__/v2-renderer.test.ts +189 -0
  14. package/src/__tests__/v2-types.test.ts +74 -0
  15. package/src/__tests__/week2-day1.test.ts +62 -0
  16. package/src/__tests__/week2-day2.test.ts +128 -0
  17. package/src/__tests__/week2-day3.test.ts +128 -0
  18. package/src/__tests__/week2-day4.test.ts +177 -0
  19. package/src/__tests__/week2-day5.test.ts +294 -0
  20. package/src/__tests__/week3-day1.test.ts +169 -0
  21. package/src/__tests__/week3-day2.test.ts +267 -0
  22. package/src/__tests__/week3-day3.test.ts +213 -0
  23. package/src/__tests__/week3-day4.test.ts +213 -0
  24. package/src/__tests__/week3-day5.test.ts +350 -0
  25. package/src/__tests__/week4-day1.test.ts +277 -0
  26. package/src/__tests__/week4-day2.test.ts +227 -0
  27. package/src/__tests__/week4-day3.test.ts +323 -0
  28. package/src/__tests__/week4-day4.test.ts +210 -0
  29. package/src/__tests__/week4-day5.test.ts +503 -0
  30. package/src/__tests__/week5-day1.test.ts +152 -0
  31. package/src/__tests__/week5-day2.test.ts +222 -0
  32. package/src/__tests__/week5-day3.test.ts +297 -0
  33. package/src/__tests__/week5-day4.test.ts +306 -0
  34. package/src/__tests__/week5-day5.test.ts +345 -0
  35. package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
  36. package/src/auto-generate/context-collector.ts +47 -0
  37. package/src/auto-generate/flow-generator-client.ts +97 -0
  38. package/src/browser.ts +5 -0
  39. package/src/components/celebration.ts +44 -0
  40. package/src/components/checklist-css.ts +159 -0
  41. package/src/components/checklist.ts +295 -0
  42. package/src/components/modal-css.ts +96 -0
  43. package/src/components/modal.ts +171 -0
  44. package/src/components/shadow-host.ts +30 -0
  45. package/src/core/api-client.ts +39 -0
  46. package/src/core/api-flows.ts +204 -0
  47. package/src/core/config.ts +37 -0
  48. package/src/core/event-batcher.ts +169 -0
  49. package/src/core/sdk.ts +301 -0
  50. package/src/detection/user-detection.ts +55 -0
  51. package/src/index.ts +95 -0
  52. package/src/snapshot/dom-collector.ts +193 -0
  53. package/src/snapshot/sender.ts +105 -0
  54. package/src/storage/event-listener.ts +59 -0
  55. package/src/storage/progress-tracker.ts +78 -0
  56. package/src/styles/checklist-css.ts +159 -0
  57. package/src/styles/checklist.css +166 -0
  58. package/src/styles/modal-css.ts +96 -0
  59. package/src/styles/modal.css +102 -0
  60. package/src/utils/dom.ts +49 -0
  61. package/src/utils/fingerprint.ts +20 -0
  62. package/src/utils/logger.ts +17 -0
  63. package/src/v2/positioner.ts +105 -0
  64. package/src/v2/renderer.ts +287 -0
  65. package/src/v2/styles.ts +89 -0
  66. package/src/v2/types.ts +53 -0
  67. package/tsconfig.json +11 -0
  68. package/vite.config.ts +28 -0
  69. 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
+ })