Skip to content

Commit 27f4709

Browse files
committed
Implement proper webComponent pattern for Lit web components in Social/Masodon, update tests
1 parent c9d9b65 commit 27f4709

13 files changed

Lines changed: 629 additions & 775 deletions

File tree

‎src/components/Social/Mastodon/__tests__/config.spec.ts‎

Lines changed: 0 additions & 91 deletions
This file was deleted.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
import MastodonModal from '../../index.astro'
3+
const { id = 'mastodon-modal' } = Astro.props
4+
---
5+
6+
<MastodonModal id={id} />
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { mastodonConfig, buildShareUrl } from '@components/Social/Mastodon/client/config'
3+
4+
describe('mastodonConfig', () => {
5+
test('uses share endpoint with text param', () => {
6+
expect(mastodonConfig.endpoint).toBe('share')
7+
expect(mastodonConfig.params.text).toBe('text')
8+
})
9+
})
10+
11+
describe('buildShareUrl', () => {
12+
test('builds share URL for plain domain', () => {
13+
const url = buildShareUrl('mastodon.social', 'Hello World')
14+
expect(url).toBe('https://mastodon.social/share?text=Hello+World')
15+
})
16+
17+
test('strips protocol and trailing slash', () => {
18+
const url = buildShareUrl('https://mastodon.social/', 'Test')
19+
expect(url).toBe('https://mastodon.social/share?text=Test')
20+
})
21+
22+
test('supports custom ports and unicode', () => {
23+
const url = buildShareUrl('mastodon.local:3000', 'Hello 世界')
24+
expect(url).toBe('https://mastodon.local:3000/share?text=Hello+%E4%B8%96%E7%95%8C')
25+
})
26+
})
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// @vitest-environment node
2+
import { describe, expect, test, vi, beforeEach } from 'vitest'
3+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
4+
import MastodonFixture from '@components/Social/Mastodon/client/__fixtures__/index.fixture.astro'
5+
import type { MastodonModalElement } from '@components/Social/Mastodon/client'
6+
import { executeRender, withJsdomEnvironment } from '@test/unit/helpers/litRuntime'
7+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
8+
import {
9+
saveMastodonInstance,
10+
setCurrentMastodonInstance,
11+
getCurrentMastodonInstance,
12+
subscribeMastodonInstances,
13+
} from '@components/scripts/store/mastodonInstances'
14+
import { isMastodonInstance } from '@components/Social/Mastodon/client/detector'
15+
import { buildShareUrl } from '@components/Social/Mastodon/client/config'
16+
17+
const savedInstanceSubscribers: Array<(_instances: Set<string>) => void> = []
18+
19+
vi.mock('focus-trap', () => {
20+
const activate = vi.fn()
21+
const deactivate = vi.fn()
22+
return {
23+
createFocusTrap: vi.fn(() => ({ activate, deactivate })),
24+
}
25+
})
26+
27+
vi.mock('@components/scripts/errors', () => ({
28+
addScriptBreadcrumb: vi.fn(),
29+
}))
30+
31+
vi.mock('@components/scripts/errors/handler', () => ({
32+
handleScriptError: vi.fn(),
33+
}))
34+
35+
vi.mock('@components/scripts/store/mastodonInstances', () => ({
36+
saveMastodonInstance: vi.fn(),
37+
setCurrentMastodonInstance: vi.fn(),
38+
getCurrentMastodonInstance: vi.fn(),
39+
subscribeMastodonInstances: vi.fn((callback: (_instances: Set<string>) => void) => {
40+
savedInstanceSubscribers.push(callback)
41+
return () => {
42+
const index = savedInstanceSubscribers.indexOf(callback)
43+
if (index >= 0) {
44+
savedInstanceSubscribers.splice(index, 1)
45+
}
46+
}
47+
}),
48+
}))
49+
50+
vi.mock('@components/Social/Mastodon/client/detector', () => ({
51+
isMastodonInstance: vi.fn(),
52+
getUrlDomain: vi.fn((value: string | URL) =>
53+
typeof value === 'string' ? value.replace(/^https?:\/\//, '') : value.host
54+
),
55+
normalizeURL: vi.fn((value: string) => (value.startsWith('http') ? value : `https://${value}`)),
56+
}))
57+
58+
vi.mock('@components/Social/Mastodon/client/config', () => ({
59+
buildShareUrl: vi.fn((instance: string, text: string) => `https://${instance}/share?text=${encodeURIComponent(text)}`),
60+
mastodonConfig: { endpoint: 'share', params: { text: 'text' } },
61+
}))
62+
63+
const mockIsMastodonInstance = vi.mocked(isMastodonInstance)
64+
const mockBuildShareUrl = vi.mocked(buildShareUrl)
65+
const mockGetCurrentInstance = vi.mocked(getCurrentMastodonInstance)
66+
const mockSaveInstance = vi.mocked(saveMastodonInstance)
67+
const mockSetCurrentInstance = vi.mocked(setCurrentMastodonInstance)
68+
const mockSubscribeInstances = vi.mocked(subscribeMastodonInstances)
69+
70+
const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0))
71+
72+
const renderModal = async (
73+
assertion: (_context: { element: MastodonModalElement; window: Window & typeof globalThis }) => Promise<void>
74+
) => {
75+
const container = await AstroContainer.create()
76+
77+
await executeRender<WebComponentModule<MastodonModalElement>>({
78+
container,
79+
component: MastodonFixture,
80+
moduleSpecifier: '@components/Social/Mastodon/client/index',
81+
waitForReady: async (element) => {
82+
await element.updateComplete
83+
},
84+
assert: async ({ element, window }) => {
85+
await assertion({ element, window: window as Window & typeof globalThis })
86+
},
87+
})
88+
}
89+
90+
describe('MastodonModalElement', () => {
91+
beforeEach(() => {
92+
vi.clearAllMocks()
93+
savedInstanceSubscribers.splice(0, savedInstanceSubscribers.length)
94+
mockIsMastodonInstance.mockResolvedValue(true)
95+
mockBuildShareUrl.mockReturnValue('https://mastodon.social/share?text=Test')
96+
mockGetCurrentInstance.mockReturnValue(undefined)
97+
})
98+
99+
test('renders hidden modal by default', async () => {
100+
await renderModal(async ({ element }) => {
101+
const dialog = element.querySelector('[role="dialog"]') as HTMLElement | null
102+
expect(dialog?.hasAttribute('hidden')).toBe(true)
103+
expect(element.open).toBe(false)
104+
})
105+
})
106+
107+
test('openModal shows modal and populates text', async () => {
108+
await renderModal(async ({ element }) => {
109+
element.openModal('Highlight text to share')
110+
await flushMicrotasks()
111+
112+
const dialog = element.querySelector('[role="dialog"]') as HTMLElement | null
113+
const textarea = element.querySelector('#share-text') as HTMLTextAreaElement | null
114+
115+
expect(dialog?.hasAttribute('hidden')).toBe(false)
116+
expect(textarea?.value.trim()).toBe('Highlight text to share')
117+
})
118+
})
119+
120+
test('submits share request when instance is valid', async () => {
121+
await renderModal(async ({ element, window }) => {
122+
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
123+
124+
element.openModal('Shareable quote')
125+
await flushMicrotasks()
126+
127+
const input = element.querySelector('#mastodon-instance') as HTMLInputElement
128+
input.value = 'mastodon.social'
129+
input.dispatchEvent(new window.Event('input', { bubbles: true }))
130+
131+
const rememberCheckbox = element.querySelector('#remember-instance') as HTMLInputElement
132+
rememberCheckbox.checked = true
133+
rememberCheckbox.dispatchEvent(new window.Event('change', { bubbles: true }))
134+
135+
const form = element.querySelector('form') as HTMLFormElement
136+
form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true }))
137+
138+
await flushMicrotasks()
139+
140+
expect(mockIsMastodonInstance).toHaveBeenCalledWith('mastodon.social')
141+
expect(mockSaveInstance).toHaveBeenCalled()
142+
expect(mockSetCurrentInstance).toHaveBeenCalledWith('mastodon.social')
143+
expect(openSpy).toHaveBeenCalledWith('https://mastodon.social/share?text=Test', '_blank', 'noopener,noreferrer')
144+
})
145+
})
146+
147+
test('shows error when domain is not Mastodon', async () => {
148+
mockIsMastodonInstance.mockResolvedValueOnce(false)
149+
150+
await renderModal(async ({ element, window }) => {
151+
element.openModal('Share text')
152+
await flushMicrotasks()
153+
154+
const input = element.querySelector('#mastodon-instance') as HTMLInputElement
155+
input.value = 'not-mastodon.example'
156+
input.dispatchEvent(new window.Event('input', { bubbles: true }))
157+
158+
const form = element.querySelector('form') as HTMLFormElement
159+
form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true }))
160+
161+
await flushMicrotasks()
162+
163+
const status = element.querySelector('.modal-status') as HTMLElement
164+
expect(status.textContent).toContain('does not appear')
165+
expect(mockSetCurrentInstance).not.toHaveBeenCalled()
166+
})
167+
})
168+
169+
test('renders saved instances from store updates', async () => {
170+
await renderModal(async ({ element, window }) => {
171+
const subscriber = mockSubscribeInstances.mock.calls.at(0)?.[0]
172+
subscriber?.(new Set(['mastodon.social']))
173+
await flushMicrotasks()
174+
175+
const savedButton = element.querySelector('.saved-instance') as HTMLButtonElement
176+
expect(savedButton?.textContent?.trim()).toBe('mastodon.social')
177+
178+
savedButton?.dispatchEvent(new window.Event('click', { bubbles: true }))
179+
const input = element.querySelector('#mastodon-instance') as HTMLInputElement
180+
expect(input.value).toBe('mastodon.social')
181+
})
182+
})
183+
184+
test('MastodonModal helper dispatches open events', async () => {
185+
await withJsdomEnvironment(async ({ window }) => {
186+
const { MastodonModal } = await import('@components/Social/Mastodon/client/index')
187+
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
188+
189+
MastodonModal.openModal('Helper text')
190+
191+
expect(dispatchSpy).toHaveBeenCalledWith(
192+
expect.objectContaining({ type: 'mastodon:open-modal', detail: { text: 'Helper text' } })
193+
)
194+
195+
dispatchSpy.mockRestore()
196+
})
197+
})
198+
})

0 commit comments

Comments
 (0)