Skip to content

Commit 5993c5f

Browse files
committed
Update unit tests for Newsletter component
1 parent 00704b2 commit 5993c5f

12 files changed

Lines changed: 324 additions & 283 deletions

File tree

_TODO.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ dsarVerificationEmails.spec.ts can't run because RESEND_API_KEY isn't set. Provi
1818

1919
5. componentDiscovery.spec.ts expects an error string but receives an object; ensure the helper throws an Error with the message the test asserts.
2020

21+
## Newsletter units
22+
23+
Next options (if needed):
24+
25+
1. silence the expected Lit "dev mode" console output in tests
26+
2. expand the selector error assertions to cover the new metadata fields.
27+
2128
## Typing client-side API calls and SSR API endpoints
2229

2330
Shared Types vs Swagger / Keeping Docs in Sync

src/components/CallToAction/Newsletter/client/__fixtures__/client.fixture.astro renamed to src/components/CallToAction/Newsletter/client/__fixtures__/index.fixture.astro

File renamed without changes.

src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts

Lines changed: 144 additions & 239 deletions
Large diffs are not rendered by default.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// @vitest-environment node
2+
import { beforeEach, describe, expect, test } from 'vitest'
3+
import { Window } from 'happy-dom'
4+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
5+
import { SELECTORS, getNewsletterElements } from '@components/CallToAction/Newsletter/client/selectors'
6+
import NewsletterFixture from '@components/CallToAction/Newsletter/client/__fixtures__/index.fixture.astro'
7+
8+
const getRootElement = (win: Window): Element => {
9+
const root = win.document.querySelector('newsletter-form')
10+
if (!root) {
11+
throw new Error('Failed to find <newsletter-form> root in selectors.spec.ts')
12+
}
13+
return root as unknown as Element
14+
}
15+
16+
describe('Newsletter selector utilities', () => {
17+
let container: AstroContainer
18+
let windowInstance: Window
19+
20+
beforeEach(async () => {
21+
container = await AstroContainer.create()
22+
const html = await container.renderToString(NewsletterFixture)
23+
windowInstance = new Window()
24+
windowInstance.document.body.innerHTML = html
25+
26+
Object.assign(globalThis, {
27+
Node: windowInstance.Node,
28+
Element: windowInstance.Element,
29+
HTMLElement: windowInstance.HTMLElement,
30+
HTMLFormElement: windowInstance.HTMLFormElement,
31+
HTMLInputElement: windowInstance.HTMLInputElement,
32+
HTMLButtonElement: windowInstance.HTMLButtonElement,
33+
HTMLSpanElement: windowInstance.HTMLSpanElement,
34+
HTMLParagraphElement: windowInstance.HTMLParagraphElement,
35+
SVGElement: windowInstance.SVGElement,
36+
SVGSVGElement: windowInstance.SVGSVGElement,
37+
})
38+
})
39+
40+
test('returns every required element from the rendered fixture', () => {
41+
const elements = getNewsletterElements(getRootElement(windowInstance))
42+
43+
expect(elements.form).toBeInstanceOf(HTMLFormElement)
44+
expect(elements.emailInput).toBeInstanceOf(HTMLInputElement)
45+
expect(elements.consentCheckbox).toBeInstanceOf(HTMLInputElement)
46+
expect(elements.submitButton).toBeInstanceOf(HTMLButtonElement)
47+
expect(elements.buttonText).toBeInstanceOf(HTMLSpanElement)
48+
expect(elements.buttonArrow).toBeInstanceOf(SVGSVGElement)
49+
expect(elements.buttonSpinner).toBeInstanceOf(SVGSVGElement)
50+
expect(elements.message).toBeInstanceOf(HTMLParagraphElement)
51+
})
52+
53+
test('throws ClientScriptError when a required selector is missing', () => {
54+
const root = getRootElement(windowInstance)
55+
root.querySelector(SELECTORS.emailInput)?.remove()
56+
57+
expect(() => getNewsletterElements(root)).toThrowError('Email input element not found')
58+
})
59+
})

src/components/CallToAction/Newsletter/index.astro

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,36 +36,36 @@ const {
3636
} = Astro.props
3737
---
3838

39-
<section class="py-12 md:py-16 bg-bg-offset">
40-
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
41-
<div class="max-w-4xl mx-auto">
42-
<div
43-
class="bg-bg rounded-2xl shadow-lg p-8 md:p-12 border border-border"
44-
>
45-
<div class="text-center mb-8">
46-
<div
47-
class="inline-flex items-center justify-center w-16 h-16 bg-linear-to-br from-primary to-accent rounded-full mb-6"
48-
>
49-
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
50-
<path
51-
stroke-linecap="round"
52-
stroke-linejoin="round"
53-
stroke-width="2"
54-
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
55-
></path>
56-
</svg>
39+
<newsletter-form class="block">
40+
<section class="py-12 md:py-16 bg-bg-offset">
41+
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
42+
<div class="max-w-4xl mx-auto">
43+
<div
44+
class="bg-bg rounded-2xl shadow-lg p-8 md:p-12 border border-border"
45+
>
46+
<div class="text-center mb-8">
47+
<div
48+
class="inline-flex items-center justify-center w-16 h-16 bg-linear-to-br from-primary to-accent rounded-full mb-6"
49+
>
50+
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
51+
<path
52+
stroke-linecap="round"
53+
stroke-linejoin="round"
54+
stroke-width="2"
55+
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
56+
></path>
57+
</svg>
58+
</div>
59+
<h2 class="text-2xl md:text-3xl lg:text-4xl font-bold text-text mb-4">
60+
{title}
61+
</h2>
62+
<p
63+
class="text-base md:text-lg text-text-offset max-w-2xl mx-auto leading-relaxed"
64+
>
65+
{description}
66+
</p>
5767
</div>
58-
<h2 class="text-2xl md:text-3xl lg:text-4xl font-bold text-text mb-4">
59-
{title}
60-
</h2>
61-
<p
62-
class="text-base md:text-lg text-text-offset max-w-2xl mx-auto leading-relaxed"
63-
>
64-
{description}
65-
</p>
66-
</div>
6768

68-
<newsletter-form class="block max-w-xl mx-auto">
6969
<form id="newsletter-form" class="max-w-xl mx-auto">
7070
<div class="space-y-4">
7171
<!-- Email Input -->
@@ -142,11 +142,11 @@ const {
142142
</p>
143143
</div>
144144
</form>
145-
</newsletter-form>
145+
</div>
146146
</div>
147147
</div>
148-
</div>
149-
</section>
148+
</section>
149+
</newsletter-form>
150150

151151
<script>
152152
import '@components/CallToAction/Newsletter/client'

src/components/Carousel/__fixtures__/client.fixture.astro renamed to src/components/Carousel/client/__fixtures__/index.fixture.astro

File renamed without changes.

src/components/Carousel/__tests__/client.spec.ts renamed to src/components/Carousel/client/__tests__/index.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ vi.mock('embla-carousel-autoplay', () => ({
3636
}))
3737

3838
// Import CarouselManager after mocking
39-
const { CarouselManager } = await import('../client')
39+
const { CarouselManager } = await import('../client/client')
4040

4141
describe('CarouselManager', () => {
4242
let container: Awaited<ReturnType<typeof AstroContainer.create>>

src/components/Carousel/index.astro

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ if (limit) {
8787
items.length > 0 && (
8888
<section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
8989
<header class="text-center mb-12">
90-
<h2 class="text-3xl md:text-4xl font-bold text-[color:var(--color-text)]">{title}</h2>
90+
<h2 class="text-3xl md:text-4xl font-bold text-text">{title}</h2>
9191
</header>
9292

9393
{/* Embla Carousel */}
@@ -99,25 +99,25 @@ if (limit) {
9999
<article class="group h-full">
100100
<a
101101
href={`/${type}/${item.id}`}
102-
class="block h-full bg-[color:var(--color-bg)] rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden border border-[color:var(--color-border)] hover:border-[color:var(--color-primary)] transform hover:-translate-y-2"
102+
class="block h-full bg-bg rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden border border-border hover:border-primary transform hover:-translate-y-2"
103103
>
104104
{'icon' in item.data && item.data.icon && (
105105
<div class="p-6 pb-4">
106-
<div class="w-12 h-12 bg-[color:var(--color-bg-offset)] rounded-lg flex items-center justify-center group-hover:bg-[color:var(--color-primary)] group-hover:bg-opacity-20 transition-colors">
106+
<div class="w-12 h-12 bg-bg-offset rounded-lg flex items-center justify-center group-hover:bg-primary group-hover:bg-opacity-20 transition-colors">
107107
<img src={item.data.icon} alt="" loading="lazy" class="w-6 h-6" />
108108
</div>
109109
</div>
110110
)}
111111
<div class="p-6 pt-2">
112-
<h3 class="text-xl font-semibold text-[color:var(--color-text)] mb-3 group-hover:text-[color:var(--color-primary)] transition-colors">
112+
<h3 class="text-xl font-semibold text-text mb-3 group-hover:text-primary transition-colors">
113113
{item.data.title}
114114
</h3>
115115
{item.data.description && (
116-
<p class="text-[color:var(--color-text-offset)] leading-relaxed text-sm">
116+
<p class="text-offset leading-relaxed text-sm">
117117
{item.data.description}
118118
</p>
119119
)}
120-
<div class="mt-4 flex items-center text-[color:var(--color-primary)] text-sm font-medium opacity-0 group-hover:opacity-100 transition-opacity">
120+
<div class="mt-4 flex items-center text-primary text-sm font-medium opacity-0 group-hover:opacity-100 transition-opacity">
121121
<span>Learn more</span>
122122
<svg
123123
class="ml-1 w-4 h-4 transform group-hover:translate-x-1 transition-transform"
@@ -144,11 +144,11 @@ if (limit) {
144144
{/* Navigation Buttons */}
145145
<button
146146
type="button"
147-
class="embla__button embla__button--prev absolute left-0 top-1/2 -translate-y-1/2 -translate-x-4 w-12 h-12 rounded-full bg-[color:var(--color-bg)] border-2 border-[color:var(--color-border)] shadow-lg hover:bg-[color:var(--color-primary)] hover:border-[color:var(--color-primary)] transition-all duration-300 flex items-center justify-center z-10 disabled:opacity-30 disabled:cursor-not-allowed"
147+
class="embla__button embla__button--prev absolute left-0 top-1/2 -translate-y-1/2 -translate-x-4 w-12 h-12 rounded-full bg-bg border-2 border-border shadow-lg hover:bg-primary hover:border-primary transition-all duration-300 flex items-center justify-center z-10 disabled:opacity-30 disabled:cursor-not-allowed"
148148
aria-label="Previous slide"
149149
>
150150
<svg
151-
class="w-6 h-6 text-[color:var(--color-text)]"
151+
class="w-6 h-6 text-text"
152152
fill="none"
153153
stroke="currentColor"
154154
viewBox="0 0 24 24"
@@ -164,11 +164,11 @@ if (limit) {
164164

165165
<button
166166
type="button"
167-
class="embla__button embla__button--next absolute right-0 top-1/2 -translate-y-1/2 translate-x-4 w-12 h-12 rounded-full bg-[color:var(--color-bg)] border-2 border-[color:var(--color-border)] shadow-lg hover:bg-[color:var(--color-primary)] hover:border-[color:var(--color-primary)] transition-all duration-300 flex items-center justify-center z-10 disabled:opacity-30 disabled:cursor-not-allowed"
167+
class="embla__button embla__button--next absolute right-0 top-1/2 -translate-y-1/2 translate-x-4 w-12 h-12 rounded-full bg-bg border-2 border-border shadow-lg hover:bg-primary hover:border-primary transition-all duration-300 flex items-center justify-center z-10 disabled:opacity-30 disabled:cursor-not-allowed"
168168
aria-label="Next slide"
169169
>
170170
<svg
171-
class="w-6 h-6 text-[color:var(--color-text)]"
171+
class="w-6 h-6 text-text"
172172
fill="none"
173173
stroke="currentColor"
174174
viewBox="0 0 24 24"

src/components/scripts/errors/converters.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export const normalizeMessage = (message: unknown): ClientScriptErrorParams => {
4242
return convertFromErrorEvent(message)
4343
} else if (isPromiseRejectionEvent(message)) {
4444
return convertFromPromiseRejectionEvent(message)
45+
} else if (isMessageLikeObject(message)) {
46+
return convertFromMessageObject(message)
4547
} else {
4648
return convertFromPrimitive(message)
4749
}
@@ -99,6 +101,37 @@ export function convertFromError(error: Error): ClientScriptErrorParams {
99101
})
100102
}
101103

104+
type MessageLikeObject = {
105+
message?: unknown
106+
stack?: unknown
107+
cause?: unknown
108+
fileName?: unknown
109+
columnNumber?: unknown
110+
lineNumber?: unknown
111+
}
112+
113+
const isRecord = (value: unknown): value is Record<string | number | symbol, unknown> => {
114+
return typeof value === 'object' && value !== null
115+
}
116+
117+
const isMessageLikeObject = (value: unknown): value is MessageLikeObject => {
118+
return isRecord(value) && 'message' in value
119+
}
120+
121+
const coerceOptionalString = (input: unknown): string | undefined =>
122+
typeof input === 'string' ? input : input === undefined ? undefined : String(input)
123+
124+
export const convertFromMessageObject = (input: MessageLikeObject): ClientScriptErrorParams => {
125+
return createErrorParams({
126+
message: coerceOptionalString(input.message) ?? '',
127+
stack: coerceOptionalString(input.stack),
128+
cause: input,
129+
fileName: coerceOptionalString(input.fileName),
130+
columnNumber: coerceOptionalString(input.columnNumber),
131+
lineNumber: coerceOptionalString(input.lineNumber),
132+
})
133+
}
134+
102135
/**
103136
* Convert ClientScriptError to ClientScriptErrorParams
104137
*/

0 commit comments

Comments
 (0)