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,115 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { computeTooltipPosition, computeHighlightRect } from '../v2/positioner'
3
+
4
+ const baseInput = {
5
+ targetRect: { top: 100, left: 200, width: 80, height: 40 },
6
+ tooltipWidth: 120,
7
+ tooltipHeight: 60,
8
+ scrollX: 0,
9
+ scrollY: 0,
10
+ viewportWidth: 1280,
11
+ viewportHeight: 800,
12
+ }
13
+
14
+ describe('computeTooltipPosition — placement basics', () => {
15
+ it('places ABOVE the target on placement=top, horizontally centred', () => {
16
+ const r = computeTooltipPosition({ ...baseInput, placement: 'top' })
17
+ // top: 100 - 60 - 8 = 32
18
+ // left: 200 + 40 - 60 = 180
19
+ expect(r.top).toBe(32)
20
+ expect(r.left).toBe(180)
21
+ })
22
+
23
+ it('places BELOW the target on placement=bottom', () => {
24
+ const r = computeTooltipPosition({ ...baseInput, placement: 'bottom' })
25
+ // top: 100 + 40 + 8 = 148
26
+ expect(r.top).toBe(148)
27
+ expect(r.left).toBe(180)
28
+ })
29
+
30
+ it('places to the RIGHT on placement=right, vertically centred', () => {
31
+ const r = computeTooltipPosition({ ...baseInput, placement: 'right' })
32
+ // top: 100 + 20 - 30 = 90
33
+ // left: 200 + 80 + 8 = 288
34
+ expect(r.top).toBe(90)
35
+ expect(r.left).toBe(288)
36
+ })
37
+
38
+ it('places to the LEFT on placement=left', () => {
39
+ const r = computeTooltipPosition({ ...baseInput, placement: 'left' })
40
+ // left: 200 - 120 - 8 = 72
41
+ expect(r.left).toBe(72)
42
+ expect(r.top).toBe(90)
43
+ })
44
+
45
+ it('places centred in the viewport on placement=center', () => {
46
+ const r = computeTooltipPosition({ ...baseInput, placement: 'center' })
47
+ // top: 800/2 - 60/2 = 370
48
+ // left: 1280/2 - 120/2 = 580
49
+ expect(r.top).toBe(370)
50
+ expect(r.left).toBe(580)
51
+ })
52
+ })
53
+
54
+ describe('computeTooltipPosition — viewport clamping', () => {
55
+ it('clamps when target near top edge would push tooltip negative', () => {
56
+ const r = computeTooltipPosition({
57
+ ...baseInput,
58
+ targetRect: { top: 4, left: 200, width: 80, height: 20 },
59
+ placement: 'top',
60
+ })
61
+ // Without clamp: 4 - 60 - 8 = -64 → clamped to 8 (GAP)
62
+ expect(r.top).toBe(8)
63
+ })
64
+
65
+ it('clamps when tooltip would extend past right edge', () => {
66
+ const r = computeTooltipPosition({
67
+ ...baseInput,
68
+ targetRect: { top: 100, left: 1250, width: 20, height: 20 },
69
+ placement: 'right',
70
+ })
71
+ // Without clamp: 1250 + 20 + 8 = 1278; tooltip width 120 → ends at 1398 > vw
72
+ // Clamped: 1280 - 120 - 8 = 1152
73
+ expect(r.left).toBe(1152)
74
+ })
75
+
76
+ it('clamps when tooltip would extend past bottom edge', () => {
77
+ const r = computeTooltipPosition({
78
+ ...baseInput,
79
+ targetRect: { top: 750, left: 200, width: 80, height: 40 },
80
+ placement: 'bottom',
81
+ })
82
+ // Without clamp: 750 + 40 + 8 = 798; tooltip height 60 → ends at 858 > 800
83
+ // Clamped: 800 - 60 - 8 = 732
84
+ expect(r.top).toBe(732)
85
+ })
86
+ })
87
+
88
+ describe('computeTooltipPosition — page scroll', () => {
89
+ it('adds scroll offsets to the page-absolute coordinates', () => {
90
+ const r = computeTooltipPosition({
91
+ ...baseInput,
92
+ placement: 'bottom',
93
+ scrollX: 100,
94
+ scrollY: 200,
95
+ })
96
+ // top (viewport): 100 + 40 + 8 = 148
97
+ // left (viewport): 200 + 40 - 60 = 180
98
+ expect(r.top).toBe(148 + 200)
99
+ expect(r.left).toBe(180 + 100)
100
+ })
101
+ })
102
+
103
+ describe('computeHighlightRect', () => {
104
+ it('inflates by 4px on each side and adds scroll offset', () => {
105
+ const r = computeHighlightRect({
106
+ targetRect: { top: 100, left: 200, width: 80, height: 40 },
107
+ scrollX: 50,
108
+ scrollY: 25,
109
+ })
110
+ expect(r.top).toBe(100 - 4 + 25)
111
+ expect(r.left).toBe(200 - 4 + 50)
112
+ expect(r.width).toBe(80 + 8)
113
+ expect(r.height).toBe(40 + 8)
114
+ })
115
+ })
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { renderV2Flow } from '../v2/renderer'
3
+ import type { V2FlowConfig } from '../v2/types'
4
+
5
+ function setupShadowRoot(): ShadowRoot {
6
+ document.body.innerHTML = ''
7
+ const host = document.createElement('div')
8
+ document.body.appendChild(host)
9
+ return host.attachShadow({ mode: 'open' })
10
+ }
11
+
12
+ function configWith(stepOverrides: Array<Partial<{
13
+ id: string
14
+ type: 'tooltip' | 'modal' | 'highlight'
15
+ title: string
16
+ description: string
17
+ selector: string
18
+ placement: 'top' | 'bottom' | 'left' | 'right' | 'center'
19
+ actionType: 'next' | 'skip' | 'complete'
20
+ actionLabel: string
21
+ }>>): V2FlowConfig {
22
+ return {
23
+ steps: stepOverrides.map((o, i) => ({
24
+ id: o.id ?? `step-${i + 1}`,
25
+ title: o.title ?? `Step ${i + 1}`,
26
+ description: o.description ?? '',
27
+ type: (o.type as 'tooltip' | 'modal' | 'highlight') ?? 'modal',
28
+ position: {
29
+ selector: o.selector ?? 'body',
30
+ placement: (o.placement as 'top'|'bottom'|'left'|'right'|'center') ?? 'center',
31
+ },
32
+ action: {
33
+ type: (o.actionType as 'next' | 'skip' | 'complete') ?? (i === stepOverrides.length - 1 ? 'complete' : 'next'),
34
+ label: o.actionLabel ?? 'Next',
35
+ },
36
+ })),
37
+ }
38
+ }
39
+
40
+ beforeEach(() => {
41
+ document.body.innerHTML = ''
42
+ })
43
+
44
+ describe('renderV2Flow — first render', () => {
45
+ it('mounts a modal step with overlay and card', () => {
46
+ const root = setupShadowRoot()
47
+ const handle = renderV2Flow(
48
+ configWith([{ type: 'modal', title: 'Welcome', description: 'Get started' }]),
49
+ root,
50
+ )
51
+ expect(handle).not.toBeNull()
52
+ expect(root.querySelector('.om2-overlay')).not.toBeNull()
53
+ expect(root.querySelector('.om2-modal-card')).not.toBeNull()
54
+ expect(root.querySelector('.om2-title')?.textContent).toBe('Welcome')
55
+ expect(root.querySelector('.om2-description')?.textContent).toBe('Get started')
56
+ })
57
+
58
+ it('mounts a tooltip step (no overlay)', () => {
59
+ const root = setupShadowRoot()
60
+ document.body.innerHTML = '<button id="cta">Go</button>'
61
+ const handle = renderV2Flow(
62
+ configWith([{ type: 'tooltip', selector: '#cta', placement: 'bottom' }]),
63
+ root,
64
+ )
65
+ expect(handle).not.toBeNull()
66
+ expect(root.querySelector('.om2-tooltip')).not.toBeNull()
67
+ expect(root.querySelector('.om2-overlay')).toBeNull()
68
+ })
69
+
70
+ it('mounts a highlight step with both ring and tooltip', () => {
71
+ const root = setupShadowRoot()
72
+ document.body.innerHTML = '<button id="cta">Go</button>'
73
+ renderV2Flow(
74
+ configWith([{ type: 'highlight', selector: '#cta' }]),
75
+ root,
76
+ )
77
+ expect(root.querySelector('.om2-highlight')).not.toBeNull()
78
+ // Highlight steps still get an actionable card alongside the ring.
79
+ expect(root.querySelectorAll('.om2-tooltip, .om2-modal-card').length).toBeGreaterThanOrEqual(1)
80
+ })
81
+
82
+ it('falls back to centred card when tooltip selector misses', () => {
83
+ const root = setupShadowRoot()
84
+ document.body.innerHTML = '<div></div>' // no #missing element
85
+ renderV2Flow(
86
+ configWith([{ type: 'tooltip', selector: '#missing' }]),
87
+ root,
88
+ )
89
+ // Tooltip card mounts even without a target.
90
+ const tooltip = root.querySelector('.om2-tooltip') as HTMLElement | null
91
+ expect(tooltip).not.toBeNull()
92
+ })
93
+
94
+ it('returns null and renders nothing when steps is empty', () => {
95
+ const root = setupShadowRoot()
96
+ const handle = renderV2Flow({ steps: [] }, root)
97
+ expect(handle).toBeNull()
98
+ expect(root.querySelector('.om2-tooltip')).toBeNull()
99
+ })
100
+
101
+ it('injects styles only once even when called multiple times', () => {
102
+ const root = setupShadowRoot()
103
+ renderV2Flow(configWith([{}]), root)
104
+ renderV2Flow(configWith([{}]), root)
105
+ const styles = root.querySelectorAll('style[data-onboardme-v2-styles]')
106
+ expect(styles.length).toBe(1)
107
+ })
108
+ })
109
+
110
+ describe('renderV2Flow — multi-step navigation', () => {
111
+ it('advances to the next step on Next click', () => {
112
+ const root = setupShadowRoot()
113
+ renderV2Flow(
114
+ configWith([
115
+ { title: 'First', type: 'modal', actionType: 'next' },
116
+ { title: 'Second', type: 'modal', actionType: 'complete', actionLabel: 'Done' },
117
+ ]),
118
+ root,
119
+ )
120
+ expect(root.querySelector('.om2-title')?.textContent).toBe('First')
121
+
122
+ const primary = root.querySelector('.om2-btn-primary') as HTMLButtonElement
123
+ primary.click()
124
+
125
+ expect(root.querySelector('.om2-title')?.textContent).toBe('Second')
126
+ })
127
+
128
+ it('tears down all DOM when the last step is completed', () => {
129
+ const root = setupShadowRoot()
130
+ renderV2Flow(
131
+ configWith([{ type: 'modal', actionType: 'complete', actionLabel: 'Done' }]),
132
+ root,
133
+ )
134
+ expect(root.querySelector('.om2-modal-card')).not.toBeNull()
135
+
136
+ const primary = root.querySelector('.om2-btn-primary') as HTMLButtonElement
137
+ primary.click()
138
+
139
+ expect(root.querySelector('.om2-modal-card')).toBeNull()
140
+ expect(root.querySelector('.om2-overlay')).toBeNull()
141
+ })
142
+
143
+ it('skip button removes everything immediately, mid-flow', () => {
144
+ const root = setupShadowRoot()
145
+ renderV2Flow(
146
+ configWith([
147
+ { title: 'First', type: 'modal' },
148
+ { title: 'Second', type: 'modal' },
149
+ ]),
150
+ root,
151
+ )
152
+ // Skip is rendered when more than one step remains AND not the last step.
153
+ const skipBtn = Array.from(root.querySelectorAll('.om2-btn-secondary')).find(
154
+ (b) => b.textContent === 'Skip',
155
+ ) as HTMLButtonElement | undefined
156
+ expect(skipBtn).toBeDefined()
157
+ skipBtn!.click()
158
+
159
+ expect(root.querySelector('.om2-modal-card')).toBeNull()
160
+ })
161
+
162
+ it('shows a step counter (n / total)', () => {
163
+ const root = setupShadowRoot()
164
+ renderV2Flow(configWith([{}, {}, {}]), root)
165
+ expect(root.querySelector('.om2-step-counter')?.textContent).toBe('1 / 3')
166
+ })
167
+
168
+ it('handle.destroy() removes everything', () => {
169
+ const root = setupShadowRoot()
170
+ const handle = renderV2Flow(configWith([{ type: 'modal' }]), root)
171
+ expect(handle).not.toBeNull()
172
+ handle!.destroy()
173
+ expect(root.querySelector('.om2-modal-card')).toBeNull()
174
+ // Calling destroy a second time is safe (no throw).
175
+ handle!.destroy()
176
+ })
177
+ })
178
+
179
+ describe('renderV2Flow — action labels', () => {
180
+ it('uses the configured action label on the primary button', () => {
181
+ const root = setupShadowRoot()
182
+ renderV2Flow(
183
+ configWith([{ type: 'modal', actionType: 'next', actionLabel: 'Got it' }]),
184
+ root,
185
+ )
186
+ const primary = root.querySelector('.om2-btn-primary') as HTMLButtonElement
187
+ expect(primary.textContent).toBe('Got it')
188
+ })
189
+ })
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { isV2FlowConfig } from '../v2/types'
3
+
4
+ const validV2Step = (overrides: Record<string, unknown> = {}) => ({
5
+ id: 'step-1',
6
+ title: 'T',
7
+ description: 'D',
8
+ type: 'modal',
9
+ position: { selector: 'body', placement: 'center' },
10
+ action: { type: 'next', label: 'Next' },
11
+ ...overrides,
12
+ })
13
+
14
+ describe('isV2FlowConfig', () => {
15
+ it('returns true for a valid v2 config', () => {
16
+ expect(isV2FlowConfig({ steps: [validV2Step()] })).toBe(true)
17
+ })
18
+
19
+ it('returns true for v2 with three step types', () => {
20
+ expect(isV2FlowConfig({
21
+ steps: [
22
+ validV2Step({ type: 'tooltip', position: { selector: '.x', placement: 'bottom' } }),
23
+ validV2Step({ id: 's2', type: 'highlight' }),
24
+ validV2Step({ id: 's3' }),
25
+ ],
26
+ })).toBe(true)
27
+ })
28
+
29
+ it('returns false for null / non-object', () => {
30
+ expect(isV2FlowConfig(null)).toBe(false)
31
+ expect(isV2FlowConfig(undefined)).toBe(false)
32
+ expect(isV2FlowConfig('x')).toBe(false)
33
+ expect(isV2FlowConfig(42)).toBe(false)
34
+ })
35
+
36
+ it('returns false when steps is missing or empty', () => {
37
+ expect(isV2FlowConfig({})).toBe(false)
38
+ expect(isV2FlowConfig({ steps: [] })).toBe(false)
39
+ expect(isV2FlowConfig({ steps: 'x' })).toBe(false)
40
+ })
41
+
42
+ it('returns false for legacy schema (body + primaryCta + targetSelector)', () => {
43
+ const legacy = {
44
+ steps: [{
45
+ id: 'step-1',
46
+ type: 'modal',
47
+ order: 0,
48
+ title: 'Welcome',
49
+ body: 'Get started', // ← legacy: body, not description
50
+ targetSelector: 'body', // ← legacy: targetSelector, not position
51
+ primaryCta: 'Next', // ← legacy: primaryCta, not action
52
+ }],
53
+ }
54
+ expect(isV2FlowConfig(legacy)).toBe(false)
55
+ })
56
+
57
+ it('returns false when position is missing required fields', () => {
58
+ expect(isV2FlowConfig({
59
+ steps: [validV2Step({ position: { placement: 'center' } })], // no selector
60
+ })).toBe(false)
61
+ expect(isV2FlowConfig({
62
+ steps: [validV2Step({ position: { selector: 'body' } })], // no placement
63
+ })).toBe(false)
64
+ })
65
+
66
+ it('returns false when action is missing required fields', () => {
67
+ expect(isV2FlowConfig({
68
+ steps: [validV2Step({ action: { label: 'Go' } })], // no type
69
+ })).toBe(false)
70
+ expect(isV2FlowConfig({
71
+ steps: [validV2Step({ action: { type: 'next' } })], // no label
72
+ })).toBe(false)
73
+ })
74
+ })
@@ -0,0 +1,62 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { getShadowRoot } from '../components/shadow-host.js';
3
+
4
+ describe('Week 2 Day 1 — Shadow DOM container (shadow-host.ts)', () => {
5
+ beforeEach(() => {
6
+ // Reset DOM between tests
7
+ document.body.innerHTML = '';
8
+ });
9
+
10
+ it('appends #onboardme-root to document.body', () => {
11
+ getShadowRoot();
12
+ expect(document.getElementById('onboardme-root')).not.toBeNull();
13
+ });
14
+
15
+ it('attaches an open Shadow DOM to #onboardme-root', () => {
16
+ const shadow = getShadowRoot();
17
+ expect(shadow).toBeInstanceOf(ShadowRoot);
18
+ expect(shadow.mode).toBe('open');
19
+ });
20
+
21
+ it('injects a <style> placeholder inside the shadow root', () => {
22
+ const shadow = getShadowRoot();
23
+ const style = shadow.querySelector('style');
24
+ expect(style).not.toBeNull();
25
+ expect(style?.dataset['onboardme']).toBe('styles');
26
+ });
27
+
28
+ it('returns the same shadow root on repeated calls (no duplicate host)', () => {
29
+ const first = getShadowRoot();
30
+ const second = getShadowRoot();
31
+ expect(first).toBe(second);
32
+ expect(document.querySelectorAll('#onboardme-root').length).toBe(1);
33
+ });
34
+
35
+ it('does not create a second #onboardme-root if called multiple times', () => {
36
+ getShadowRoot();
37
+ getShadowRoot();
38
+ getShadowRoot();
39
+ expect(document.querySelectorAll('#onboardme-root').length).toBe(1);
40
+ });
41
+
42
+ it('shadow root is isolated — a style added to body does not appear inside shadow root', () => {
43
+ const shadow = getShadowRoot();
44
+ // Add a style to the host document body (outside shadow)
45
+ const hostStyle = document.createElement('style');
46
+ hostStyle.textContent = '.host-class { color: red; }';
47
+ document.head.appendChild(hostStyle);
48
+
49
+ // The shadow root should only contain the placeholder style, not the host one
50
+ const shadowStyles = shadow.querySelectorAll('style');
51
+ const hasHostStyle = Array.from(shadowStyles).some(
52
+ (s) => s.textContent === '.host-class { color: red; }',
53
+ );
54
+ expect(hasHostStyle).toBe(false);
55
+ });
56
+
57
+ it('host element is a direct child of document.body', () => {
58
+ getShadowRoot();
59
+ const host = document.getElementById('onboardme-root');
60
+ expect(host?.parentElement).toBe(document.body);
61
+ });
62
+ });
@@ -0,0 +1,128 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { getShadowRoot } from '../components/shadow-host.js';
3
+ import { renderModal } from '../components/modal.js';
4
+ import type { FlowStep } from '@onboardme/types';
5
+
6
+ // Minimal valid FlowStep for testing
7
+ function makeStep(overrides: Partial<FlowStep> = {}): FlowStep {
8
+ return {
9
+ id: 'step-1',
10
+ type: 'modal',
11
+ order: 1,
12
+ title: 'Welcome to Acme',
13
+ body: 'Let us show you around.',
14
+ trigger: { type: 'page_load' },
15
+ dismissible: true,
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe('Week 2 Day 2 — Modal HTML structure (modal.ts)', () => {
21
+ let shadow: ShadowRoot;
22
+
23
+ beforeEach(() => {
24
+ document.body.innerHTML = '';
25
+ shadow = getShadowRoot();
26
+ });
27
+
28
+ // --- DOM structure ---
29
+
30
+ it('renders an overlay element inside the shadow root', () => {
31
+ renderModal(makeStep(), shadow);
32
+ expect(shadow.querySelector('[data-onboardme="overlay"]')).not.toBeNull();
33
+ });
34
+
35
+ it('overlay has class om-overlay', () => {
36
+ renderModal(makeStep(), shadow);
37
+ expect(shadow.querySelector('.om-overlay')).not.toBeNull();
38
+ });
39
+
40
+ it('renders a modal card with class om-modal inside the overlay', () => {
41
+ renderModal(makeStep(), shadow);
42
+ const overlay = shadow.querySelector('.om-overlay');
43
+ expect(overlay?.querySelector('.om-modal')).not.toBeNull();
44
+ });
45
+
46
+ it('modal has role="dialog" and aria-modal="true"', () => {
47
+ renderModal(makeStep(), shadow);
48
+ const modal = shadow.querySelector('.om-modal');
49
+ expect(modal?.getAttribute('role')).toBe('dialog');
50
+ expect(modal?.getAttribute('aria-modal')).toBe('true');
51
+ });
52
+
53
+ // --- Content ---
54
+
55
+ it('renders step.title as the modal heading text', () => {
56
+ renderModal(makeStep({ title: 'Hello World' }), shadow);
57
+ expect(shadow.querySelector('.om-modal__title')?.textContent).toBe('Hello World');
58
+ });
59
+
60
+ it('renders step.body as the modal body text', () => {
61
+ renderModal(makeStep({ body: 'This is the body.' }), shadow);
62
+ expect(shadow.querySelector('.om-modal__body')?.textContent).toBe('This is the body.');
63
+ });
64
+
65
+ // --- CTA defaults ---
66
+
67
+ it('uses "Get started" as primary CTA when step.primaryCta is absent', () => {
68
+ renderModal(makeStep(), shadow);
69
+ const btn = shadow.querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]');
70
+ expect(btn?.textContent).toBe('Get started');
71
+ });
72
+
73
+ it('uses step.primaryCta when provided', () => {
74
+ renderModal(makeStep({ primaryCta: 'Start tour' }), shadow);
75
+ const btn = shadow.querySelector('[data-onboardme="primary-cta"]');
76
+ expect(btn?.textContent).toBe('Start tour');
77
+ });
78
+
79
+ it('uses "Skip for now" as skip label when step.secondaryCta is absent', () => {
80
+ renderModal(makeStep(), shadow);
81
+ const btn = shadow.querySelector('[data-onboardme="skip-cta"]');
82
+ expect(btn?.textContent).toBe('Skip for now');
83
+ });
84
+
85
+ it('uses step.secondaryCta when provided', () => {
86
+ renderModal(makeStep({ secondaryCta: 'Maybe later' }), shadow);
87
+ const btn = shadow.querySelector('[data-onboardme="skip-cta"]');
88
+ expect(btn?.textContent).toBe('Maybe later');
89
+ });
90
+
91
+ // --- Dismissible flag ---
92
+
93
+ it('renders the skip button when dismissible is true', () => {
94
+ renderModal(makeStep({ dismissible: true }), shadow);
95
+ expect(shadow.querySelector('[data-onboardme="skip-cta"]')).not.toBeNull();
96
+ });
97
+
98
+ it('hides the skip button when dismissible is false', () => {
99
+ renderModal(makeStep({ dismissible: false }), shadow);
100
+ expect(shadow.querySelector('[data-onboardme="skip-cta"]')).toBeNull();
101
+ });
102
+
103
+ it('renders the skip button when dismissible is omitted (default true)', () => {
104
+ const step = makeStep();
105
+ delete (step as Partial<FlowStep>).dismissible;
106
+ renderModal(step, shadow);
107
+ expect(shadow.querySelector('[data-onboardme="skip-cta"]')).not.toBeNull();
108
+ });
109
+
110
+ // --- CSS injection ---
111
+
112
+ it('populates the placeholder <style> element with modal CSS', () => {
113
+ renderModal(makeStep(), shadow);
114
+ const style = shadow.querySelector<HTMLStyleElement>('style[data-onboardme="styles"]');
115
+ expect(style?.textContent).toBeTruthy();
116
+ expect(style?.textContent).toContain('.om-overlay');
117
+ expect(style?.textContent).toContain('.om-modal');
118
+ });
119
+
120
+ it('does not inject styles into the host document head', () => {
121
+ renderModal(makeStep(), shadow);
122
+ const hostStyles = document.head.querySelectorAll('style');
123
+ const leaked = Array.from(hostStyles).some(
124
+ (s) => s.textContent?.includes('.om-overlay'),
125
+ );
126
+ expect(leaked).toBe(false);
127
+ });
128
+ });