Skip to content

Commit 13a9758

Browse files
committed
Refactor ThemePicker unit test to node + DOMParser workflow after refactor of component to web component
1 parent 2229aa1 commit 13a9758

4 files changed

Lines changed: 140 additions & 78 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
/**
3+
* Minimal Sprite mock for ThemePicker tests.
4+
* Provides predictable markup without pulling in astro-icon integration.
5+
*/
6+
export interface Props {
7+
class?: string
8+
name: string
9+
size?: number | string
10+
variant?: string
11+
}
12+
13+
const { class: className, name } = Astro.props
14+
---
15+
16+
<svg
17+
data-testid="sprite-mock"
18+
data-icon-name={name}
19+
class={className}
20+
aria-hidden="true"
21+
focusable="false"
22+
/>
Lines changed: 117 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,115 @@
1-
// @vitest-environment happy-dom
1+
// @vitest-environment node
22
/**
33
* ThemePicker Web Component Unit Tests
44
* Tests the LitElement-based theme picker functionality
55
*/
66
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
7+
import { Window } from 'happy-dom'
78
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
89
import ThemePickerFixture from '@components/ThemePicker/__fixtures__/client.fixture.astro'
10+
import type { Props as ThemePickerFixtureProps } from '@components/ThemePicker/__fixtures__/client.fixture.astro'
11+
12+
vi.mock(
13+
'@components/Sprite/index.astro',
14+
async () => await import('@components/ThemePicker/__mocks__/SpriteMock.astro')
15+
)
16+
17+
type ParsedDocument = ReturnType<Window['DOMParser']['prototype']['parseFromString']>
18+
19+
interface RenderResult {
20+
document: ParsedDocument
21+
}
22+
23+
const queryElement = <T extends Element>(selector: string, targetDoc: ParsedDocument): T => {
24+
const element = targetDoc.querySelector(selector) as T | null
25+
if (!element) {
26+
throw new Error(`Expected element matching "${selector}" to exist`)
27+
}
28+
return element
29+
}
30+
31+
const queryElements = <T extends Element>(selector: string, targetDoc: ParsedDocument): T[] =>
32+
Array.from(targetDoc.querySelectorAll(selector)) as unknown as T[]
33+
34+
const renderFixture = async (
35+
container: AstroContainer,
36+
props?: ThemePickerFixtureProps
37+
): Promise<RenderResult> => {
38+
const renderOptions = props ? { props: props as Record<string, unknown> } : undefined
39+
const html = await container.renderToString(ThemePickerFixture, renderOptions)
40+
const window = new Window()
41+
const parser = new window.DOMParser()
42+
const document = parser.parseFromString(html, 'text/html')
43+
return { document }
44+
}
945

1046
describe('ThemePicker Component', () => {
1147
let container: AstroContainer
12-
let html: string
48+
let documentRef: ParsedDocument
1349

1450
beforeEach(async () => {
1551
container = await AstroContainer.create()
16-
html = await container.renderToString(ThemePickerFixture)
17-
document.body.innerHTML = html
18-
19-
// Wait for all async operations (including Lit component initialization) to complete
20-
await (window as any).happyDOM.whenAsyncComplete()
21-
22-
// Additional tick to ensure event handlers are bound
23-
await new Promise(resolve => setTimeout(resolve, 0))
52+
const renderResult = await renderFixture(container)
53+
documentRef = renderResult.document
2454
})
2555

2656
afterEach(() => {
27-
document.body.innerHTML = ''
2857
vi.restoreAllMocks()
2958
})
3059

60+
const getThemePickerElement = (doc = documentRef) => queryElement<HTMLElement>('theme-picker', doc)
61+
const getToggleButton = (doc = documentRef) => queryElement<HTMLButtonElement>('.themepicker-toggle__toggle-btn', doc)
62+
const getThemeModal = (doc = documentRef) => queryElement<HTMLElement>('[data-theme-modal]', doc)
63+
const getThemeCloseButton = (doc = documentRef) => queryElement<HTMLButtonElement>('[data-theme-close]', doc)
64+
const getThemeList = (doc = documentRef) => queryElement<HTMLElement>('#theme-menu', doc)
65+
const getThemeListItems = (doc = documentRef) => queryElements<HTMLLIElement>('.themepicker__item', doc)
66+
const getThemeButtons = (doc = documentRef) => queryElements<HTMLButtonElement>('[data-theme]', doc)
67+
const getThemeSelectButtons = (doc = documentRef) => queryElements<HTMLButtonElement>('.themepicker__selectBtn', doc)
68+
3169
describe('Component Structure', () => {
3270
it('should render theme-picker custom element', () => {
33-
const themePicker = document.querySelector('theme-picker')
34-
expect(themePicker).toBeTruthy()
71+
const themePicker = getThemePickerElement()
72+
expect(themePicker.tagName.toLowerCase()).toBe('theme-picker')
3573
})
3674

3775
it('should render toggle button in header', () => {
38-
const toggleBtn = document.querySelector('.themepicker-toggle__toggle-btn')
39-
expect(toggleBtn).toBeTruthy()
40-
expect(toggleBtn?.getAttribute('aria-label')).toBe('toggle theme switcher')
41-
expect(toggleBtn?.getAttribute('aria-haspopup')).toBe('true')
76+
const toggleBtn = getToggleButton()
77+
expect(toggleBtn.getAttribute('aria-label')).toBe('toggle theme switcher')
78+
expect(toggleBtn.getAttribute('aria-haspopup')).toBe('true')
4279
})
4380

4481
it('should render modal with correct attributes', () => {
45-
const modal = document.querySelector('[data-theme-modal]')
46-
expect(modal).toBeTruthy()
47-
expect(modal?.hasAttribute('hidden')).toBe(true)
48-
expect(modal?.classList.contains('themepicker')).toBe(true)
82+
const modal = getThemeModal()
83+
expect(modal.hasAttribute('hidden')).toBe(true)
84+
expect(modal.classList.contains('themepicker')).toBe(true)
4985
})
5086

5187
it('should render theme selection buttons', () => {
52-
const themeButtons = document.querySelectorAll('[data-theme]')
88+
const themeButtons = getThemeButtons()
5389
expect(themeButtons.length).toBeGreaterThan(0)
5490

5591
// Should have at least light and dark (holiday is seasonal and may be commented out)
56-
const themes = Array.from(themeButtons).map(btn => btn.getAttribute('data-theme'))
92+
const themes = themeButtons.map(btn => btn.getAttribute('data-theme'))
5793
expect(themes).toContain('light')
5894
expect(themes).toContain('dark')
5995
})
6096

6197
it('should render close button', () => {
62-
const closeBtn = document.querySelector('[data-theme-close]')
63-
expect(closeBtn).toBeTruthy()
64-
expect(closeBtn?.getAttribute('aria-label')).toBe('Close theme picker dialog')
98+
const closeBtn = getThemeCloseButton()
99+
expect(closeBtn.getAttribute('aria-label')).toBe('Close theme picker dialog')
65100
})
66101
})
67102

68103
describe('Accessibility', () => {
69104
it('should have proper ARIA attributes on toggle button', () => {
70-
const toggleBtn = document.querySelector('.themepicker-toggle__toggle-btn')
71-
expect(toggleBtn?.getAttribute('aria-expanded')).toBe('false')
72-
expect(toggleBtn?.getAttribute('aria-owns')).toBe('theme-menu')
73-
expect(toggleBtn?.getAttribute('aria-haspopup')).toBe('true')
105+
const toggleBtn = getToggleButton()
106+
expect(toggleBtn.getAttribute('aria-expanded')).toBe('false')
107+
expect(toggleBtn.getAttribute('aria-owns')).toBe('theme-menu')
108+
expect(toggleBtn.getAttribute('aria-haspopup')).toBe('true')
74109
})
75110

76111
it('should have accessible labels on theme buttons', () => {
77-
const themeButtons = document.querySelectorAll('[data-theme]')
112+
const themeButtons = getThemeButtons()
78113
themeButtons.forEach(button => {
79114
const label = button.getAttribute('aria-label')
80115
expect(label).toBeTruthy()
@@ -83,37 +118,36 @@ describe('ThemePicker Component', () => {
83118
})
84119

85120
it('should have role="banner" on header', () => {
86-
const header = document.querySelector('#header')
87-
expect(header?.getAttribute('role')).toBe('banner')
121+
const header = queryElement<HTMLElement>('#header', documentRef)
122+
expect(header.getAttribute('role')).toBe('banner')
88123
})
89124
})
90125

91126
describe('Component Props', () => {
92127
it('should accept custom label prop', async () => {
93-
const customHtml = await container.renderToString(ThemePickerFixture, {
94-
props: { label: 'Custom Theme Label' }
95-
})
96-
expect(customHtml).toContain('Custom Theme Label')
128+
const { document: customDoc } = await renderFixture(container, { label: 'Custom Theme Label' })
129+
const heading = queryElement<HTMLHeadingElement>('.themepicker h3', customDoc)
130+
expect(heading.textContent?.trim()).toBe('Custom Theme Label')
97131
})
98132

99133
it('should use default label when not provided', () => {
100-
const heading = document.querySelector('.themepicker h3')
101-
expect(heading?.textContent?.trim()).toBe('Select theme')
134+
const heading = queryElement<HTMLHeadingElement>('.themepicker h3', documentRef)
135+
expect(heading.textContent?.trim()).toBe('Select theme')
102136
})
103137
})
104138

105139
describe('CSS Classes', () => {
106140
it('should have Tailwind utility classes on modal', () => {
107-
const modal = document.querySelector('[data-theme-modal]')
108-
expect(modal?.classList.contains('bg-bg-offset')).toBe(true)
109-
expect(modal?.classList.contains('block')).toBe(true)
110-
expect(modal?.classList.contains('overflow-hidden')).toBe(true)
111-
expect(modal?.classList.contains('relative')).toBe(true)
112-
expect(modal?.classList.contains('w-full')).toBe(true)
141+
const modal = getThemeModal()
142+
expect(modal.classList.contains('bg-bg-offset')).toBe(true)
143+
expect(modal.classList.contains('block')).toBe(true)
144+
expect(modal.classList.contains('overflow-hidden')).toBe(true)
145+
expect(modal.classList.contains('relative')).toBe(true)
146+
expect(modal.classList.contains('w-full')).toBe(true)
113147
})
114148

115149
it('should have hover classes on theme items', () => {
116-
const items = document.querySelectorAll('.themepicker__item')
150+
const items = getThemeListItems()
117151
items.forEach(item => {
118152
expect(item.classList.contains('hover:shadow-md')).toBe(true)
119153
expect(item.classList.contains('hover:scale-105')).toBe(true)
@@ -123,12 +157,12 @@ describe('ThemePicker Component', () => {
123157

124158
describe('Data Attributes', () => {
125159
it('should mark modal with data-nosnippet', () => {
126-
const modal = document.querySelector('[data-theme-modal]')
127-
expect(modal?.hasAttribute('data-nosnippet')).toBe(true)
160+
const modal = getThemeModal()
161+
expect(modal.hasAttribute('data-nosnippet')).toBe(true)
128162
})
129163

130164
it('should have data-theme attributes on selection buttons', () => {
131-
const buttons = document.querySelectorAll('.themepicker__selectBtn')
165+
const buttons = getThemeSelectButtons()
132166
buttons.forEach(button => {
133167
expect(button.hasAttribute('data-theme')).toBe(true)
134168
const theme = button.getAttribute('data-theme')
@@ -139,22 +173,21 @@ describe('ThemePicker Component', () => {
139173

140174
describe('Theme List Structure', () => {
141175
it('should render theme list with correct ID', () => {
142-
const list = document.querySelector('#theme-menu')
143-
expect(list).toBeTruthy()
144-
expect(list?.classList.contains('themepicker__list')).toBe(true)
176+
const list = getThemeList()
177+
expect(list.classList.contains('themepicker__list')).toBe(true)
145178
})
146179

147180
it('should render theme items as list elements', () => {
148-
const items = document.querySelectorAll('.themepicker__item')
181+
const items = getThemeListItems()
149182
items.forEach(item => {
150183
expect(item.tagName).toBe('LI')
151184
})
152185
})
153186

154187
it('should have proper button structure in each theme item', () => {
155-
const items = document.querySelectorAll('.themepicker__item')
188+
const items = getThemeListItems()
156189
items.forEach(item => {
157-
const button = item.querySelector('button')
190+
const button = item.querySelector('button') as HTMLButtonElement | null
158191
expect(button).toBeTruthy()
159192
expect(button?.classList.contains('themepicker__selectBtn')).toBe(true)
160193
})
@@ -163,7 +196,7 @@ describe('ThemePicker Component', () => {
163196

164197
describe('Color Palette Display', () => {
165198
it('should display color hues for each theme', () => {
166-
const items = document.querySelectorAll('.themepicker__item')
199+
const items = getThemeListItems()
167200
items.forEach(item => {
168201
const hues = item.querySelectorAll('.themepicker__hue')
169202
// Each theme should show 5 color hues
@@ -172,42 +205,51 @@ describe('ThemePicker Component', () => {
172205
})
173206

174207
it('should have correct Tailwind color classes on hues', () => {
175-
const firstItem = document.querySelector('.themepicker__item')
176-
const hues = firstItem?.querySelectorAll('.themepicker__hue')
177-
178-
expect(hues?.[0].classList.contains('bg-primary')).toBe(true)
179-
expect(hues?.[1].classList.contains('bg-secondary')).toBe(true)
180-
expect(hues?.[2].classList.contains('bg-border')).toBe(true)
181-
expect(hues?.[3].classList.contains('bg-text-offset')).toBe(true)
182-
expect(hues?.[4].classList.contains('bg-text')).toBe(true)
208+
const items = getThemeListItems()
209+
expect(items.length).toBeGreaterThan(0)
210+
const firstItem = items[0]
211+
if (!firstItem) {
212+
throw new Error('Expected at least one theme list item')
213+
}
214+
215+
const hues = Array.from(firstItem.querySelectorAll('.themepicker__hue')) as HTMLElement[]
216+
const expectedClasses = ['bg-primary', 'bg-secondary', 'bg-border', 'bg-text-offset', 'bg-text']
217+
218+
expectedClasses.forEach((className, index) => {
219+
const hue = hues[index]
220+
if (!hue) {
221+
throw new Error(`Missing hue at index ${index}`)
222+
}
223+
expect(hue.classList.contains(className)).toBe(true)
224+
})
183225
})
184226
})
185227

186228
describe('Close Button', () => {
187229
it('should render sprite/icon for close button', () => {
188-
const closeBtn = document.querySelector('[data-theme-close]')
189-
const svg = closeBtn?.querySelector('svg')
230+
const closeBtn = getThemeCloseButton()
231+
const svg = closeBtn.querySelector('svg')
190232
expect(svg).toBeTruthy()
191233
})
192234

193235
it('should have proper styling classes on close button', () => {
194-
const closeBtn = document.querySelector('[data-theme-close]')
195-
expect(closeBtn?.classList.contains('bg-secondary')).toBe(true)
196-
expect(closeBtn?.classList.contains('text-text')).toBe(true)
197-
expect(closeBtn?.classList.contains('absolute')).toBe(true)
236+
const closeBtn = getThemeCloseButton()
237+
expect(closeBtn.classList.contains('bg-secondary')).toBe(true)
238+
expect(closeBtn.classList.contains('text-text')).toBe(true)
239+
expect(closeBtn.classList.contains('absolute')).toBe(true)
198240
})
199241
})
200242

201243
describe('Responsive Design', () => {
202244
it('should have horizontal scroll on theme list', () => {
203-
const list = document.querySelector('.themepicker__list')
204-
expect(list?.classList.contains('overflow-x-auto')).toBe(true)
205-
expect(list?.classList.contains('overflow-y-hidden')).toBe(true)
245+
const list = queryElement<HTMLElement>('.themepicker__list', documentRef)
246+
expect(list.classList.contains('overflow-x-auto')).toBe(true)
247+
expect(list.classList.contains('overflow-y-hidden')).toBe(true)
206248
})
207249

208250
it('should have whitespace-nowrap for horizontal layout', () => {
209-
const list = document.querySelector('.themepicker__list')
210-
expect(list?.classList.contains('whitespace-nowrap')).toBe(true)
251+
const list = queryElement<HTMLElement>('.themepicker__list', documentRef)
252+
expect(list.classList.contains('whitespace-nowrap')).toBe(true)
211253
})
212254
})
213255
})

‎src/components/scripts/@types/PromiseRejectionEvent.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
/**
22
* The PromiseRejectionEvent constructor is a Web API and not provided by Happy Dom
3-
*
4-
* @see [PromiseRejectionEvent is not defined](https://github.com/jsdom/jsdom/issues/2401)
53
*/
64
export type PromiseRejectionEventTypes = 'rejectionhandled' | 'unhandledrejection'
75

‎src/components/scripts/utils/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export {
1616
} from './dataSubjectId'
1717

1818
/**
19-
* !!!! DO NOT RE-EXPORT environmentClient.ts or siteUrlClient.ts methods here !!!!!
19+
* !!!! DO NOT RE-EXPORT environmentClient.ts OR siteUrlClient.ts METHODS HERE !!!!!
2020
*
2121
* Their use of "astro:env/client" causes Rollup problems in splitting the bundle
2222
* when the methods are imported from this barrel file into both client and API contexts.

0 commit comments

Comments
 (0)