Spaces:
Sleeping
Sleeping
| // FE-ADMIN-GH-001 to FE-ADMIN-GH-016 | |
| import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; | |
| import userEvent from '@testing-library/user-event'; | |
| import { http, HttpResponse } from 'msw'; | |
| import { server } from '../../../tests/helpers/msw/server'; | |
| import { resetAllStores } from '../../../tests/helpers/store'; | |
| import GitHubPanel from './GitHubPanel'; | |
| function buildRelease(overrides = {}) { | |
| const id = Math.random(); | |
| return { | |
| id, | |
| tag_name: 'v1.0.0', | |
| name: 'Initial Release', | |
| body: '## Changes\n- Fixed bug\n- **Bold improvement**\n- `code snippet`', | |
| published_at: '2025-01-15T12:00:00Z', | |
| created_at: '2025-01-15T12:00:00Z', | |
| prerelease: false, | |
| author: { login: 'mauriceboe' }, | |
| ...overrides, | |
| }; | |
| } | |
| const PAGE_1 = Array.from({ length: 10 }, (_, i) => | |
| buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }), | |
| ); | |
| const PAGE_2 = Array.from({ length: 5 }, (_, i) => | |
| buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }), | |
| ); | |
| beforeEach(() => { | |
| resetAllStores(); | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json([])), | |
| ); | |
| }); | |
| afterEach(() => { | |
| server.resetHandlers(); | |
| }); | |
| describe('GitHubPanel', () => { | |
| it('FE-ADMIN-GH-001: support link cards always render', async () => { | |
| render(<GitHubPanel />); | |
| await waitFor(() => | |
| expect(screen.queryByRole('status')).not.toBeInTheDocument(), | |
| ); | |
| expect(screen.getByText('Ko-fi')).toBeInTheDocument(); | |
| expect(screen.getByText('Buy Me a Coffee')).toBeInTheDocument(); | |
| expect(screen.getByText('Discord')).toBeInTheDocument(); | |
| expect(screen.getByText('Report a Bug')).toBeInTheDocument(); | |
| expect(screen.getByText('Feature Request')).toBeInTheDocument(); | |
| expect(screen.getByText('Wiki')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-GH-002: all support links have correct href and target=_blank', async () => { | |
| render(<GitHubPanel />); | |
| await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument()); | |
| const kofi = screen.getByText('Ko-fi').closest('a')!; | |
| expect(kofi).toHaveAttribute('href', 'https://ko-fi.com/mauriceboe'); | |
| expect(kofi).toHaveAttribute('target', '_blank'); | |
| expect(kofi).toHaveAttribute('rel', 'noopener noreferrer'); | |
| const bmc = screen.getByText('Buy Me a Coffee').closest('a')!; | |
| expect(bmc).toHaveAttribute('href', 'https://buymeacoffee.com/mauriceboe'); | |
| expect(bmc).toHaveAttribute('target', '_blank'); | |
| expect(bmc).toHaveAttribute('rel', 'noopener noreferrer'); | |
| const discord = screen.getByText('Discord').closest('a')!; | |
| expect(discord).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW'); | |
| expect(discord).toHaveAttribute('target', '_blank'); | |
| expect(discord).toHaveAttribute('rel', 'noopener noreferrer'); | |
| }); | |
| it('FE-ADMIN-GH-003: loading spinner shown while fetching releases', () => { | |
| server.use( | |
| http.get('/api/admin/github-releases', async () => { | |
| await new Promise(() => {}); // never resolves | |
| return HttpResponse.json([]); | |
| }), | |
| ); | |
| render(<GitHubPanel />); | |
| // The Loader2 spinner is rendered while loading=true | |
| const spinner = document.querySelector('.animate-spin'); | |
| expect(spinner).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-GH-004: error state shown on API failure', async () => { | |
| server.use( | |
| http.get('/api/admin/github-releases', () => | |
| HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }), | |
| ), | |
| ); | |
| render(<GitHubPanel />); | |
| await screen.findByText('Failed to load releases'); | |
| // Timeline should not be rendered | |
| expect(screen.queryByText('Release History')).not.toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-GH-005: releases render in timeline', async () => { | |
| const r1 = buildRelease({ id: 1, tag_name: 'v1.0.0', author: { login: 'mauriceboe' } }); | |
| const r2 = buildRelease({ id: 2, tag_name: 'v1.1.0', author: { login: 'mauriceboe' } }); | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])), | |
| ); | |
| render(<GitHubPanel />); | |
| await screen.findByText('v1.0.0'); | |
| expect(screen.getByText('v1.1.0')).toBeInTheDocument(); | |
| // Author label | |
| const authorLabels = screen.getAllByText(/mauriceboe/); | |
| expect(authorLabels.length).toBeGreaterThan(0); | |
| // Some date should be visible (non-empty) | |
| const dateEls = document.querySelectorAll('[class*="text-"]'); | |
| const dateTexts = Array.from(dateEls).map(el => el.textContent).filter(t => t && t.match(/\d{4}/)); | |
| expect(dateTexts.length).toBeGreaterThan(0); | |
| }); | |
| it('FE-ADMIN-GH-006: latest badge shown only on first release', async () => { | |
| const r1 = buildRelease({ id: 1, tag_name: 'v2.0.0' }); | |
| const r2 = buildRelease({ id: 2, tag_name: 'v1.9.0' }); | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])), | |
| ); | |
| render(<GitHubPanel />); | |
| await screen.findByText('v2.0.0'); | |
| const latestBadges = screen.getAllByText('Latest'); | |
| expect(latestBadges).toHaveLength(1); | |
| }); | |
| it('FE-ADMIN-GH-007: prerelease badge shown', async () => { | |
| const r = buildRelease({ id: 10, tag_name: 'v3.0.0-beta.1', prerelease: true }); | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json([r])), | |
| ); | |
| render(<GitHubPanel isPrerelease={true} />); | |
| await screen.findByText('v3.0.0-beta.1'); | |
| expect(screen.getByText('Pre-release')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-GH-008: expand/collapse release notes', async () => { | |
| const r = buildRelease({ | |
| id: 20, | |
| tag_name: 'v1.5.0', | |
| body: '- Fixed bug\n- Another fix', | |
| }); | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json([r])), | |
| ); | |
| const user = userEvent.setup(); | |
| render(<GitHubPanel />); | |
| await screen.findByText('v1.5.0'); | |
| const showBtn = screen.getByText('Show details'); | |
| expect(showBtn).toBeInTheDocument(); | |
| // Body not visible yet | |
| expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument(); | |
| // Expand | |
| await user.click(showBtn); | |
| await screen.findByText('Fixed bug'); | |
| expect(screen.getByText('Hide details')).toBeInTheDocument(); | |
| // Collapse | |
| await user.click(screen.getByText('Hide details')); | |
| await waitFor(() => | |
| expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument(), | |
| ); | |
| expect(screen.getByText('Show details')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-GH-009: release body renders markdown: lists, bold, code', async () => { | |
| const r = buildRelease({ | |
| id: 30, | |
| tag_name: 'v1.6.0', | |
| body: '- list item\n- **bold text**\n- `inline code`', | |
| }); | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json([r])), | |
| ); | |
| const user = userEvent.setup(); | |
| render(<GitHubPanel />); | |
| await screen.findByText('v1.6.0'); | |
| await user.click(screen.getByText('Show details')); | |
| await screen.findByText('list item'); | |
| // list item is inside a <li> | |
| const listItem = screen.getByText('list item'); | |
| expect(listItem.closest('li')).toBeInTheDocument(); | |
| // Bold text rendered as <strong> | |
| const container = document.querySelector('.mt-2.p-3.rounded-lg')!; | |
| expect(container.querySelector('strong')).toBeInTheDocument(); | |
| expect(container.querySelector('strong')!.textContent).toBe('bold text'); | |
| // Code rendered as <code> | |
| expect(container.querySelector('code')).toBeInTheDocument(); | |
| expect(container.querySelector('code')!.textContent).toBe('inline code'); | |
| }); | |
| it('FE-ADMIN-GH-010: "Load more" button visible when full page returned', async () => { | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)), | |
| ); | |
| render(<GitHubPanel />); | |
| await screen.findByText(`v1.0.0`); | |
| expect(screen.getByText('Load more')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-GH-011: "Load more" hidden when partial page returned', async () => { | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)), | |
| ); | |
| render(<GitHubPanel />); | |
| await screen.findByText('v0.0.0'); | |
| expect(screen.queryByText('Load more')).not.toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-GH-013: release body renders plain paragraph text', async () => { | |
| const r = buildRelease({ | |
| id: 40, | |
| tag_name: 'v1.7.0', | |
| body: 'This is a plain paragraph without any markdown syntax.', | |
| }); | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json([r])), | |
| ); | |
| const user = userEvent.setup(); | |
| render(<GitHubPanel />); | |
| await screen.findByText('v1.7.0'); | |
| await user.click(screen.getByText('Show details')); | |
| await screen.findByText('This is a plain paragraph without any markdown syntax.'); | |
| }); | |
| it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => { | |
| const r = buildRelease({ | |
| id: 41, | |
| tag_name: 'v1.8.0', | |
| body: '- [click here](https://example.com)', | |
| }); | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json([r])), | |
| ); | |
| const user = userEvent.setup(); | |
| render(<GitHubPanel />); | |
| await screen.findByText('v1.8.0'); | |
| await user.click(screen.getByText('Show details')); | |
| const link = await screen.findByText('click here'); | |
| expect(link.closest('a') || link.tagName.toLowerCase() === 'a' ? link : null).not.toBeNull(); | |
| }); | |
| it('FE-ADMIN-GH-015: javascript: link is sanitized to #', async () => { | |
| const r = buildRelease({ | |
| id: 42, | |
| tag_name: 'v1.9.0', | |
| body: '- [evil](javascript:alert(1))', | |
| }); | |
| server.use( | |
| http.get('/api/admin/github-releases', () => HttpResponse.json([r])), | |
| ); | |
| const user = userEvent.setup(); | |
| render(<GitHubPanel />); | |
| await screen.findByText('v1.9.0'); | |
| await user.click(screen.getByText('Show details')); | |
| const link = await screen.findByText('evil'); | |
| const anchor = link.closest('a') ?? link; | |
| // The unsafe href is replaced with '#' | |
| expect(anchor).toHaveAttribute('href', '#'); | |
| }); | |
| it('FE-ADMIN-GH-016: support card hover effects fire without error', async () => { | |
| render(<GitHubPanel />); | |
| await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument()); | |
| const kofiLink = screen.getByText('Ko-fi').closest('a')!; | |
| fireEvent.mouseEnter(kofiLink); | |
| fireEvent.mouseLeave(kofiLink); | |
| const discordLink = screen.getByText('Discord').closest('a')!; | |
| fireEvent.mouseEnter(discordLink); | |
| fireEvent.mouseLeave(discordLink); | |
| const bugLink = screen.getByText('Report a Bug').closest('a')!; | |
| fireEvent.mouseEnter(bugLink); | |
| fireEvent.mouseLeave(bugLink); | |
| const featureLink = screen.getByText('Feature Request').closest('a')!; | |
| fireEvent.mouseEnter(featureLink); | |
| fireEvent.mouseLeave(featureLink); | |
| const wikiLink = screen.getByText('Wiki').closest('a')!; | |
| fireEvent.mouseEnter(wikiLink); | |
| fireEvent.mouseLeave(wikiLink); | |
| const bmcLink = screen.getByText('Buy Me a Coffee').closest('a')!; | |
| fireEvent.mouseEnter(bmcLink); | |
| fireEvent.mouseLeave(bmcLink); | |
| // All links still visible | |
| expect(screen.getByText('Ko-fi')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-GH-012: clicking "Load more" appends next page', async () => { | |
| server.use( | |
| http.get('/api/admin/github-releases', ({ request }) => { | |
| const url = new URL(request.url); | |
| const page = url.searchParams.get('page'); | |
| if (page === '2') { | |
| return HttpResponse.json(PAGE_2); | |
| } | |
| return HttpResponse.json(PAGE_1); | |
| }), | |
| ); | |
| const user = userEvent.setup(); | |
| render(<GitHubPanel />); | |
| await screen.findByText('v1.0.0'); | |
| // All 10 items from page 1 visible | |
| expect(screen.getAllByText(/v1\.\d\.0/).length).toBe(10); | |
| // Click Load more | |
| await user.click(screen.getByText('Load more')); | |
| // Wait for page 2 items to appear | |
| await screen.findByText('v0.0.0'); | |
| // Total: 10 from page 1 + 5 from page 2 = 15 | |
| const tagEls = screen.getAllByText(/^v[01]\.\d\.0$/); | |
| expect(tagEls.length).toBe(15); | |
| // Load more should be hidden (PAGE_2 < 10) | |
| expect(screen.queryByText('Load more')).not.toBeInTheDocument(); | |
| }); | |
| }); | |