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,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
|
+
});
|