Skip to content

Commit 6c3fbbe

Browse files
committed
Refactor newsletter confirmation page to Lit custom web component
1 parent 7021b58 commit 6c3fbbe

8 files changed

Lines changed: 744 additions & 344 deletions

File tree

‎_TODO.md‎

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -56,33 +56,3 @@ Right now we're using string literals to define HTML email templates for site ma
5656
Vercel AI Gateway, maybe could use for a chatbot:
5757

5858
https://vercel.com/kevin-browns-projects-dd474f73/astro-webstackbuilders-com/ai-gateway
59-
60-
1. Firefox/WebKit locator.setChecked(...) “did not change its state”
61-
62-
With your markup, the checkbox is sr-only and the visual switch is a separate element (peer-based). In Firefox/WebKit, a forced click on a clipped/visually-hidden checkbox can “click” without producing the native toggle behavior Playwright expects (or the component’s JS is not wired to the checkbox click in a way that actually flips the DOM checked state).
63-
64-
Most deterministic alternatives (conceptually):
65-
66-
- Don’t uncheck via the hidden input. Use the “Deny All” control (it exists as #consent-deny-all), then click “Allow All”, then assert all are checked. This tests the same behavior (“Allow All enables every category”) without the fragile intermediate toggle interaction.
67-
68-
- If you truly need to uncheck just one category, do it by setting state via JS (checked = false + dispatch input/change) because that bypasses the click/geometry issues and targets the state machine the component is likely listening to.
69-
70-
2. mobile-safari consent banner: waitForPageLoad() still times out (15s)
71-
72-
Important observation: in consentBanner.spec.ts, you already wait on a much more direct readiness signal right after waitForPageLoad():
73-
74-
consent-banner.isInitialized === true
75-
So the likely story here is: waitForPageLoad() is unnecessary for these tests, and when it flakes it blocks the entire suite.
76-
77-
3. mobile-safari store persistence beforeEach timeouts + Edge footer page.goto('/') timeouts
78-
79-
These are pure navigation-level failures (timeouts in page.goto or in a beforeEach that presumably can’t get through its startup navigation). The usual causes are:
80-
81-
a global readiness wait that never resolves (which can cascade into beforeEach timing out)
82-
83-
- Two questions so we pick the right fixes
84-
85-
For the consent “Allow All enables every category” test: are you okay with changing the test to use #consent-deny-all instead of unchecking a single toggle (still verifies Allow All enables everything, but avoids the hidden-switch interaction entirely)?
86-
87-
For the navigation timeouts (mobile-safari + Edge): were these runs executed while npm run dev was definitely running and stable, or is it possible the server wasn’t up / got interrupted?
88-

‎playwright.config.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const buildWebServerEnv = () => {
3535
return env
3636
}
3737

38-
const workersHighParallel = process.env['GITHUB_ACTIONS'] ? 1 : 2
38+
//const workersHighParallel = process.env['GITHUB_ACTIONS'] ? 1 : 1
3939
const testMatchHighParallel = '**/*.spec.ts'
4040
const testIgnoreHighParallel = [
4141
'03-forms/**/*.spec.ts',
@@ -66,9 +66,13 @@ export default defineConfig({
6666
forbidOnly: !!process.env['GITHUB_ACTIONS'],
6767
/** Retry on CI only */
6868
retries: process.env['GITHUB_ACTIONS'] ? 2 : 0,
69-
/** Opt out of high parallelism in CI-mode runs. */
70-
workers: workersHighParallel,
71-
/** Only run @ready tests in CI, all tests locally */
69+
/**
70+
* Global maximum workers. Setting to 1 forces serial mode for tests. Setting
71+
* higher causes flakiness especially in mobile-safari tests due to resource
72+
* contention on Vite dev server.
73+
*/
74+
workers: 1,
75+
/** Only run `@ready` tests in CI, all tests locally */
7276
...(process.env['GITHUB_ACTIONS'] ? { grep: /@ready/ } : {}),
7377
/** Reporter to use. See https://playwright.dev/docs/test-reporters */
7478
reporter: process.env['GITHUB_ACTIONS']
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
3+
import NewsletterConfirm from '@components/Newsletter/Confirm/index.astro'
4+
import type { NewsletterConfirmElement as NewsletterConfirmElementInstance } from '../index'
5+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
6+
import { executeRender } from '@test/unit/helpers/litRuntime'
7+
import { getNewsletterConfirmElements } from '../selectors'
8+
9+
type NewsletterConfirmModule = WebComponentModule<NewsletterConfirmElementInstance>
10+
11+
type ActionResult<TData> = { data?: TData; error?: { message?: string } }
12+
13+
type ConfirmActionData = {
14+
success?: boolean
15+
email?: string
16+
status?: string
17+
message?: string
18+
}
19+
20+
const confirmMock = vi.fn<
21+
(_input: { token: string }) => Promise<ActionResult<ConfirmActionData>>
22+
>()
23+
24+
vi.mock('astro:actions', () => ({
25+
actions: {
26+
newsletter: {
27+
confirm: confirmMock,
28+
},
29+
},
30+
}))
31+
32+
const flushPromises = async () => {
33+
await Promise.resolve()
34+
await new Promise(resolve => setTimeout(resolve, 0))
35+
}
36+
37+
describe('NewsletterConfirmElement web component', () => {
38+
let container: AstroContainer
39+
40+
beforeEach(async () => {
41+
confirmMock.mockReset()
42+
container = await AstroContainer.create()
43+
})
44+
45+
afterEach(() => {
46+
vi.restoreAllMocks()
47+
})
48+
49+
const renderConfirm = async (
50+
assertion: (_context: {
51+
element: NewsletterConfirmElementInstance
52+
elements: ReturnType<typeof getNewsletterConfirmElements>
53+
}) => Promise<void> | void,
54+
mockResult: ActionResult<ConfirmActionData>
55+
) => {
56+
confirmMock.mockResolvedValue(mockResult)
57+
58+
await executeRender<NewsletterConfirmModule>({
59+
container,
60+
component: NewsletterConfirm,
61+
moduleSpecifier: '@components/Newsletter/Confirm/client/index',
62+
args: {
63+
props: {
64+
token: 'unit-test-token',
65+
},
66+
},
67+
waitForReady: async (element: NewsletterConfirmElementInstance) => {
68+
element.initialize()
69+
await flushPromises()
70+
},
71+
assert: async ({ element }) => {
72+
const elements = getNewsletterConfirmElements(element)
73+
await assertion({ element, elements })
74+
},
75+
})
76+
}
77+
78+
test('shows success state when confirmation succeeds', async () => {
79+
await renderConfirm(
80+
async ({ elements }) => {
81+
expect(elements.successState.classList.contains('hidden')).toBe(false)
82+
expect(elements.loadingState.classList.contains('hidden')).toBe(true)
83+
expect(elements.userEmail.textContent).toBe('test@example.com')
84+
expect(elements.statusAnnouncer.textContent).toBe('Subscription confirmed.')
85+
},
86+
{ data: { success: true, email: 'test@example.com' } }
87+
)
88+
})
89+
90+
test('shows expired state when confirmation is expired', async () => {
91+
await renderConfirm(
92+
async ({ elements }) => {
93+
expect(elements.expiredState.classList.contains('hidden')).toBe(false)
94+
expect(elements.loadingState.classList.contains('hidden')).toBe(true)
95+
expect(elements.statusAnnouncer.textContent).toBe('Confirmation link expired.')
96+
},
97+
{ data: { success: false, status: 'expired' } }
98+
)
99+
})
100+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
3+
import NewsletterConfirm from '@components/Newsletter/Confirm/index.astro'
4+
import type { NewsletterConfirmElement as NewsletterConfirmElementInstance } from '../index'
5+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
6+
import { executeRender } from '@test/unit/helpers/litRuntime'
7+
import { getNewsletterConfirmElements } from '../selectors'
8+
9+
type NewsletterConfirmModule = WebComponentModule<NewsletterConfirmElementInstance>
10+
11+
type ActionResult<TData> = { data?: TData; error?: { message?: string } }
12+
13+
type ConfirmActionData = {
14+
success?: boolean
15+
email?: string
16+
status?: string
17+
message?: string
18+
}
19+
20+
const confirmMock = vi.fn<
21+
(_input: { token: string }) => Promise<ActionResult<ConfirmActionData>>
22+
>()
23+
24+
vi.mock('astro:actions', () => ({
25+
actions: {
26+
newsletter: {
27+
confirm: confirmMock,
28+
},
29+
},
30+
}))
31+
32+
describe('NewsletterConfirm selectors', () => {
33+
let container: AstroContainer
34+
35+
beforeEach(async () => {
36+
container = await AstroContainer.create()
37+
confirmMock.mockReset()
38+
confirmMock.mockResolvedValue({ data: { success: true, email: 'test@example.com' } })
39+
})
40+
41+
it('stays in sync with the NewsletterConfirm layout', async () => {
42+
await executeRender<NewsletterConfirmModule>({
43+
container,
44+
component: NewsletterConfirm,
45+
moduleSpecifier: '@components/Newsletter/Confirm/client/index',
46+
args: {
47+
props: {
48+
token: 'unit-test-token',
49+
},
50+
},
51+
waitForReady: async (element: NewsletterConfirmElementInstance) => {
52+
element.initialize()
53+
},
54+
assert: async ({ element }) => {
55+
const elements = getNewsletterConfirmElements(element)
56+
57+
expect(elements.loadingState.id).toBe('loading-state')
58+
expect(elements.successState.id).toBe('success-state')
59+
expect(elements.expiredState.id).toBe('expired-state')
60+
expect(elements.errorState.id).toBe('error-state')
61+
expect(elements.statusAnnouncer.id).toBe('confirmation-status')
62+
expect(elements.userEmail.id).toBe('user-email')
63+
expect(elements.errorTitle.id).toBe('error-title')
64+
expect(elements.errorMessage.id).toBe('error-message')
65+
expect(elements.errorDetails.id).toBe('error-details')
66+
67+
expect(elements.loadingHeading.tagName).toBe('H2')
68+
expect(elements.successHeading.tagName).toBe('H2')
69+
expect(elements.expiredHeading.tagName).toBe('H2')
70+
},
71+
})
72+
})
73+
})

0 commit comments

Comments
 (0)