|
| 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