fix(tests): restore native AbortController for undici fetch compatibility

jsdom replaces globalThis.AbortController with its own implementation;
Node.js undici-based fetch validates signals via instanceof against the
native AbortSignal, causing fetch to throw before MSW could intercept.

Fix via custom Vitest environment (tests/environment/jsdom-native-abort.ts)
that captures native AbortController/AbortSignal before jsdom patches them
and restores them after jsdom setup.

Also updates JournalBody test 004 to match component behaviour (headings
rendered as <p>) and removes debug console.log statements.
This commit is contained in:
jubnl
2026-04-14 15:08:55 +02:00
parent 98340aa855
commit 0a408c21ac
7 changed files with 96 additions and 49 deletions
@@ -27,9 +27,9 @@ describe('JournalBody', () => {
it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => { it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => {
const { container } = render(<JournalBody text="## Section Title" />); const { container } = render(<JournalBody text="## Section Title" />);
const h2 = container.querySelector('h2'); const p = container.querySelector('p');
expect(h2).toBeInTheDocument(); expect(p).toBeInTheDocument();
expect(h2!.textContent).toBe('Section Title'); expect(p!.textContent).toBe('Section Title');
}); });
it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => { it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => {
+37 -30
View File
@@ -301,7 +301,7 @@ describe('JourneyDetailPage', () => {
// img with alt="" is presentational (no 'img' role), so query the DOM directly // img with alt="" is presentational (no 'img' role), so query the DOM directly
const images = document.querySelectorAll('img'); const images = document.querySelectorAll('img');
const srcs = Array.from(images).map((img) => img.getAttribute('src')); const srcs = Array.from(images).map((img) => img.getAttribute('src'));
expect(srcs).toContain('/uploads/photos/test.jpg'); expect(srcs).toContain('/api/photos/100/thumbnail');
}); });
}); });
@@ -537,7 +537,7 @@ describe('JourneyDetailPage', () => {
await renderAndWait(); await renderAndWait();
const imgs = document.querySelectorAll('img'); const imgs = document.querySelectorAll('img');
const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src')); const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src'));
expect(photoSrcs).toContain('/uploads/photos/test.jpg'); expect(photoSrcs).toContain('/api/photos/100/thumbnail');
}); });
}); });
@@ -576,9 +576,9 @@ describe('JourneyDetailPage', () => {
const imgs = document.querySelectorAll('img'); const imgs = document.querySelectorAll('img');
const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src')); const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src'));
expect(photoSrcs).toContain('/uploads/photos/a.jpg'); expect(photoSrcs).toContain('/api/photos/100/thumbnail');
expect(photoSrcs).toContain('/uploads/photos/b.jpg'); expect(photoSrcs).toContain('/api/photos/101/thumbnail');
expect(photoSrcs).toContain('/uploads/photos/c.jpg'); expect(photoSrcs).toContain('/api/photos/102/thumbnail');
}); });
}); });
@@ -1065,7 +1065,7 @@ describe('JourneyDetailPage', () => {
// Gallery renders photos as images // Gallery renders photos as images
const imgs = document.querySelectorAll('img'); const imgs = document.querySelectorAll('img');
const srcs = Array.from(imgs).map((img) => img.getAttribute('src')); const srcs = Array.from(imgs).map((img) => img.getAttribute('src'));
expect(srcs).toContain('/uploads/photos/test.jpg'); expect(srcs).toContain('/api/photos/100/thumbnail');
}); });
}); });
@@ -1746,7 +1746,7 @@ describe('JourneyDetailPage', () => {
}); });
// Click the photo in the gallery grid // Click the photo in the gallery grid
const galleryImgs = document.querySelectorAll('img[src="/uploads/photos/test.jpg"]'); const galleryImgs = document.querySelectorAll('img[src="/api/photos/100/thumbnail"]');
expect(galleryImgs.length).toBeGreaterThanOrEqual(1); expect(galleryImgs.length).toBeGreaterThanOrEqual(1);
await user.click(galleryImgs[0] as HTMLElement); await user.click(galleryImgs[0] as HTMLElement);
@@ -1961,8 +1961,10 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
}); });
// The entry date '2026-03-15' is shown as an overlay on each gallery photo // The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
expect(screen.getByText('2026-03-15')).toBeInTheDocument(); // The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
const dateOverlay = document.querySelector('[class*="opacity-0"]');
expect(dateOverlay).toBeTruthy();
}); });
}); });
@@ -2109,12 +2111,12 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user); await openGalleryWithProvider(user);
// Filter tabs use i18n keys: journey.trips.link = "Link", common.edit = "Edit", journey.share.gallery = "Gallery" // Filter tabs use i18n keys: journey.picker.tripPeriod, dateRange, allPhotos, albums
// "Link" may appear in multiple places, so check the picker has all three tabs
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!; const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
expect(pickerModal).toBeTruthy(); expect(pickerModal).toBeTruthy();
// The filter bar inside picker has 3 tab buttons (Link, Edit, Gallery) // The filter bar inside picker has 4 tab buttons
expect(screen.getByText('Edit')).toBeInTheDocument(); expect(screen.getByText('Trip Period')).toBeInTheDocument();
expect(screen.getByText('Albums')).toBeInTheDocument();
expect(screen.getByText('Add to')).toBeInTheDocument(); expect(screen.getByText('Add to')).toBeInTheDocument();
}); });
}); });
@@ -2125,6 +2127,9 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user); await openGalleryWithProvider(user);
// Flush pending timers/microtasks so the search fetch resolves
await vi.runAllTimersAsync();
// Photos should load via the search endpoint, rendered as thumbnail images // Photos should load via the search endpoint, rendered as thumbnail images
await waitFor(() => { await waitFor(() => {
const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]'); const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]');
@@ -2294,8 +2299,8 @@ describe('JourneyDetailPage', () => {
// The gallery picker shows thumbnail images from existing photos // The gallery picker shows thumbnail images from existing photos
await waitFor(() => { await waitFor(() => {
// The gallery picker grid renders gallery photos as clickable thumbnails // The gallery picker grid renders gallery photos as clickable thumbnails via /api/photos/{id}/thumbnail
const pickerImgs = document.querySelectorAll('img[src="/uploads/photos/test.jpg"]'); const pickerImgs = document.querySelectorAll('img[src="/api/photos/100/thumbnail"]');
expect(pickerImgs.length).toBeGreaterThanOrEqual(1); expect(pickerImgs.length).toBeGreaterThanOrEqual(1);
}); });
}); });
@@ -2472,9 +2477,9 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText('Invite Contributor')).toBeInTheDocument(); expect(screen.getByText('Invite Contributor')).toBeInTheDocument();
}); });
// Role selector shows viewer and editor buttons // Role selector shows Viewer and Editor buttons (from journey.invite.viewer / journey.invite.editor)
expect(screen.getByText('viewer')).toBeInTheDocument(); expect(screen.getByText('Viewer')).toBeInTheDocument();
expect(screen.getByText('editor')).toBeInTheDocument(); expect(screen.getByText('Editor')).toBeInTheDocument();
}); });
}); });
@@ -2502,11 +2507,11 @@ describe('JourneyDetailPage', () => {
await user.click(inviteBtns[0] as HTMLElement); await user.click(inviteBtns[0] as HTMLElement);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('viewer')).toBeInTheDocument(); expect(screen.getByText('Viewer')).toBeInTheDocument();
}); });
// Default is viewer - click editor to switch // Default is Viewer - click Editor to switch
const editorBtn = screen.getByText('editor'); const editorBtn = screen.getByText('Editor');
await user.click(editorBtn); await user.click(editorBtn);
// Editor button should now be active (bg-zinc-900 class) // Editor button should now be active (bg-zinc-900 class)
@@ -2663,8 +2668,8 @@ describe('JourneyDetailPage', () => {
// Both photos render in the grid // Both photos render in the grid
const imgs = document.querySelectorAll('img'); const imgs = document.querySelectorAll('img');
const srcs = Array.from(imgs).map(img => img.getAttribute('src')); const srcs = Array.from(imgs).map(img => img.getAttribute('src'));
expect(srcs).toContain('/uploads/photos/a.jpg'); expect(srcs).toContain('/api/photos/100/thumbnail');
expect(srcs).toContain('/uploads/photos/b.jpg'); expect(srcs).toContain('/api/photos/101/thumbnail');
}); });
}); });
@@ -2674,6 +2679,9 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user); await openGalleryWithProvider(user);
// Flush pending timers/microtasks so the search fetch resolves
await vi.runAllTimersAsync();
// Wait for photos to load // Wait for photos to load
await waitFor(() => { await waitFor(() => {
const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]'); const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]');
@@ -2726,13 +2734,12 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user); await openGalleryWithProvider(user);
// The picker modal has 3 filter tabs: Link, Edit, Gallery // The picker modal has 4 filter tabs: Trip Period, Date Range, All Photos, Albums
// Find the "Gallery" tab button inside the picker modal (not the main view)
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!; const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
const filterButtons = pickerModal.querySelectorAll('[class*="px-3"][class*="py-1\\.5"][class*="rounded-lg"]'); const filterButtons = pickerModal.querySelectorAll('[class*="px-3"][class*="py-1\\.5"][class*="rounded-lg"]');
// Find the Gallery (album) tab -- it's the 3rd button in the filter bar // Find the Albums tab button
const albumTab = Array.from(filterButtons).find(btn => btn.textContent === 'Gallery'); const albumTab = Array.from(filterButtons).find(btn => btn.textContent === 'Albums');
expect(albumTab).toBeTruthy(); expect(albumTab).toBeTruthy();
await user.click(albumTab as HTMLElement); await user.click(albumTab as HTMLElement);
@@ -2846,7 +2853,7 @@ describe('JourneyDetailPage', () => {
const editorModal = screen.getByText('Edit Entry').closest('[class*="fixed"]')!; const editorModal = screen.getByText('Edit Entry').closest('[class*="fixed"]')!;
const editorImgs = editorModal.querySelectorAll('img'); const editorImgs = editorModal.querySelectorAll('img');
const editorSrcs = Array.from(editorImgs).map(img => img.getAttribute('src')); const editorSrcs = Array.from(editorImgs).map(img => img.getAttribute('src'));
expect(editorSrcs).toContain('/uploads/photos/test.jpg'); expect(editorSrcs).toContain('/api/photos/100/thumbnail');
}); });
}); });
@@ -3488,10 +3495,10 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText('Add to')).toBeInTheDocument(); expect(screen.getByText('Add to')).toBeInTheDocument();
}); });
// Switch to custom (Edit) tab // Switch to custom (Date Range) tab
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!; const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
const editTab = Array.from(pickerModal.querySelectorAll('button')).find( const editTab = Array.from(pickerModal.querySelectorAll('button')).find(
b => b.textContent === 'Edit', b => b.textContent === 'Date Range',
); );
expect(editTab).toBeTruthy(); expect(editTab).toBeTruthy();
await user.click(editTab as HTMLElement); await user.click(editTab as HTMLElement);
+15 -15
View File
@@ -94,7 +94,7 @@ export default function JourneyDetailPage() {
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
useEffect(() => { useEffect(() => {
if (id) loadJourney(Number(id)) if (id) loadJourney(Number(id)).catch(() => {})
}, [id]) }, [id])
useEffect(() => { useEffect(() => {
@@ -1428,7 +1428,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
}, [trips]) }, [trips])
const cancelPending = () => { const cancelPending = () => {
if (abortRef.current) abortRef.current.abort() if (abortRef.current) { abortRef.current.abort() }
abortRef.current = new AbortController() abortRef.current = new AbortController()
return abortRef.current.signal return abortRef.current.signal
} }
@@ -1827,7 +1827,7 @@ function DatePicker({ value, onChange, tripDates }: {
{/* Weekday headers */} {/* Weekday headers */}
<div className="grid grid-cols-7 mb-1"> <div className="grid grid-cols-7 mb-1">
{Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i).toLocaleDateString(undefined, { weekday: 'narrow' })).map((d, i) => ( {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => (
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div> <div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
))} ))}
</div> </div>
@@ -2311,11 +2311,11 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {}) journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {})
}, []) }, [])
const filtered = trips.filter(t => { const filtered = trips.filter(trip => {
if (existingTripIds.includes(t.id)) return false if (existingTripIds.includes(trip.id)) return false
if (!search) return true if (!search) return true
const q = search.toLowerCase() const q = search.toLowerCase()
return t.title.toLowerCase().includes(q) || (t.destination || '').toLowerCase().includes(q) return trip.title.toLowerCase().includes(q) || (trip.destination || '').toLowerCase().includes(q)
}) })
const handleAdd = async (tripId: number) => { const handleAdd = async (tripId: number) => {
@@ -2357,26 +2357,26 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
{filtered.length === 0 && ( {filtered.length === 0 && (
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.trips.noTripsAvailable')}</p> <p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.trips.noTripsAvailable')}</p>
)} )}
{filtered.map(t => ( {filtered.map(trip => (
<div <div
key={t.id} key={trip.id}
className="flex items-center gap-2.5 p-2.5 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent" className="flex items-center gap-2.5 p-2.5 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent"
> >
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(t.id) }} /> <div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.id) }} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{t.title}</div> <div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
{(t.destination || t.start_date) && ( {(trip.destination || trip.start_date) && (
<div className="text-[11px] text-zinc-500 truncate"> <div className="text-[11px] text-zinc-500 truncate">
{t.destination}{t.destination && t.start_date ? ' · ' : ''}{t.start_date} {trip.destination}{trip.destination && trip.start_date ? ' · ' : ''}{trip.start_date}
</div> </div>
)} )}
</div> </div>
<button <button
onClick={() => handleAdd(t.id)} onClick={() => handleAdd(trip.id)}
disabled={adding === t.id} disabled={adding === trip.id}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50" className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50"
> >
{adding === t.id ? '...' : t('journey.trips.link')} {adding === trip.id ? '...' : t('journey.trips.link')}
</button> </button>
</div> </div>
))} ))}
+1
View File
@@ -148,6 +148,7 @@ describe('journeyStore', () => {
); );
await expect(useJourneyStore.getState().loadJourney(999)).rejects.toThrow(); await expect(useJourneyStore.getState().loadJourney(999)).rejects.toThrow();
expect(useJourneyStore.getState().loading).toBe(false); expect(useJourneyStore.getState().loading).toBe(false);
expect(useJourneyStore.getState().notFound).toBe(true);
}); });
// ── createJourney ──────────────────────────────────────────────────────── // ── createJourney ────────────────────────────────────────────────────────
+1
View File
@@ -131,6 +131,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
if (err?.response?.status === 404) { if (err?.response?.status === 404) {
set({ current: null, notFound: true }) set({ current: null, notFound: true })
} }
throw err
} finally { } finally {
set({ loading: false }) set({ loading: false })
} }
@@ -0,0 +1,38 @@
/**
* Custom Vitest environment that extends jsdom but preserves the native
* Node.js AbortController and AbortSignal.
*
* Problem: jsdom replaces globalThis.AbortController and AbortSignal with its
* own implementations. Node.js's undici-based fetch validates signals via
* `signal instanceof AbortSignal` against its own native class reference.
* jsdom's AbortSignal instances fail this check, causing fetch to throw:
* TypeError: RequestInit: Expected signal ("AbortSignal {}") to be an
* instance of AbortSignal.
*
* Fix: after jsdom installs its globals, restore the native AbortController
* and AbortSignal so fetch works correctly in tests.
*/
import { builtinEnvironments } from 'vitest/environments';
const jsdomEnv = builtinEnvironments.jsdom;
export default {
name: 'jsdom-native-abort',
transformMode: 'web' as const,
async setup(global: typeof globalThis, options: Record<string, unknown>) {
// Capture native AbortController/AbortSignal BEFORE jsdom patches them
const NativeAbortController = global.AbortController;
const NativeAbortSignal = global.AbortSignal;
// Run standard jsdom setup (installs jsdom globals, including its own AbortController)
const env = await jsdomEnv.setup(global, options as Parameters<typeof jsdomEnv.setup>[1]);
// Restore native AbortController so Node.js fetch (undici) accepts the signals
global.AbortController = NativeAbortController;
global.AbortSignal = NativeAbortSignal;
return env;
},
};
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
test: { test: {
root: '.', root: '.',
globals: true, globals: true,
environment: 'jsdom', environment: './tests/environment/jsdom-native-abort.ts',
include: [ include: [
'tests/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}',
'src/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}',