diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 7111c2e4..0e8b731c 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -56,7 +56,7 @@ test.describe('frontend smoke', () => { await page.getByLabel('Page URL').fill('https://example.com/articles'); await page.getByRole('button', { name: 'Generate feed URL' }).click(); - await expect(page.getByRole('heading', { name: 'Add access token' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Enter access token' })).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Access token' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Save and continue' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Back' })).toBeVisible(); diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index 8a3f7e5c..79150dce 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -8,7 +8,7 @@ describe('App contract', () => { const token = 'contract-token'; const authenticate = () => { - window.localStorage.setItem('html2rss_access_token', token); + globalThis.localStorage.setItem('html2rss_access_token', token); }; it('shows feed result when API responds with success', async () => { @@ -64,7 +64,7 @@ describe('App contract', () => { fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); await waitFor(() => { - expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); + expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByText('Example Feed')).toBeInTheDocument(); expect(screen.getByLabelText('Feed URL')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); @@ -159,8 +159,8 @@ describe('App contract', () => { await screen.findByText('Access token was rejected. Paste a valid token to continue.'); - expect(screen.getByText('Add access token')).toBeInTheDocument(); - expect(screen.queryByText('Feed generation failed')).not.toBeInTheDocument(); - expect(window.localStorage.getItem('html2rss_access_token')).toBeNull(); + expect(screen.getByText('Enter access token')).toBeInTheDocument(); + expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument(); + expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull(); }); }); diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 3383146a..5f58cfa3 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; -import { h } from 'preact'; import { App } from '../components/App'; vi.mock('../hooks/useAccessToken', () => ({ @@ -35,18 +34,19 @@ describe('App', () => { const mockConvertFeed = vi.fn(); const mockClearConversionError = vi.fn(); const mockClearResult = vi.fn(); + const mockRetryReadinessCheck = vi.fn(); beforeEach(() => { vi.clearAllMocks(); - window.history.replaceState({}, '', 'http://localhost:3000/'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/'); mockUseAccessToken.mockReturnValue({ - token: null, + token: undefined, hasToken: false, saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockUseApiMetadata.mockReturnValue({ @@ -65,16 +65,17 @@ describe('App', () => { }, }, isLoading: false, - error: null, + error: undefined, }); mockUseFeedConversion.mockReturnValue({ isConverting: false, - result: null, - error: null, + result: undefined, + error: undefined, convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, + retryReadinessCheck: mockRetryReadinessCheck, }); mockUseStrategies.mockReturnValue({ @@ -83,7 +84,7 @@ describe('App', () => { { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' }, ], isLoading: false, - error: null, + error: undefined, }); }); @@ -127,7 +128,7 @@ describe('App', () => { mockUseStrategies.mockReturnValue({ strategies: [{ id: 'faraday', name: 'faraday', display_name: 'Default' }], isLoading: false, - error: null, + error: undefined, }); render(); @@ -144,9 +145,13 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); - window.history.replaceState({}, '', 'http://localhost:3000/?url=https%3A%2F%2Fexample.com%2Farticles'); + globalThis.history.replaceState( + {}, + '', + 'http://localhost:3000/?url=https%3A%2F%2Fexample.com%2Farticles' + ); render(); @@ -163,15 +168,15 @@ describe('App', () => { }); fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); - expect(screen.getByText('Add access token')).toBeInTheDocument(); + expect(screen.getByText('Enter access token')).toBeInTheDocument(); expect(screen.getByLabelText('Page URL')).toBeDisabled(); expect(screen.getByRole('combobox')).toBeDisabled(); expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Set up your own instance with Docker.' })).toBeInTheDocument(); - expect(screen.getByText('This instance needs an access token.')).toBeInTheDocument(); + expect(screen.getByText('Required by this instance.')).toBeInTheDocument(); expect(screen.queryByText('Paste an access token to keep going.')).not.toBeInTheDocument(); await waitFor(() => { - expect(document.activeElement).toBe(document.getElementById('access-token')); + expect(document.activeElement).toBe(document.querySelector('#access-token')); }); expect(mockConvertFeed).not.toHaveBeenCalled(); }); @@ -199,7 +204,7 @@ describe('App', () => { }, }, isLoading: false, - error: null, + error: undefined, }); render(); @@ -209,7 +214,7 @@ describe('App', () => { 'href', '/microsoft.com/azure-products.rss' ); - expect(screen.getByText('Custom feed generation is disabled for this instance.')).toBeInTheDocument(); + expect(screen.getByText('Feed creation is disabled on this instance.')).toBeInTheDocument(); }); it('renders the result panel when a feed is available', async () => { @@ -230,12 +235,14 @@ describe('App', () => { error: 'Preview unavailable right now.', isLoading: false, }, - retry: null, + readinessPhase: 'preview_unavailable', + retry: undefined, }, - error: null, + error: undefined, convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, + retryReadinessCheck: mockRetryReadinessCheck, }); render(); @@ -249,35 +256,35 @@ describe('App', () => { it('surfaces conversion errors to the user', () => { mockUseFeedConversion.mockReturnValue({ isConverting: false, - result: null, + result: undefined, error: 'Access denied', convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, + retryReadinessCheck: mockRetryReadinessCheck, }); render(); - expect(screen.getByText('Feed generation failed')).toBeInTheDocument(); + expect(screen.getByText('Could not create feed link')).toBeInTheDocument(); expect(screen.getByText('Access denied')).toBeInTheDocument(); }); it('shows an explicit loading notice while feed creation is still resolving preview state', () => { mockUseFeedConversion.mockReturnValue({ isConverting: true, - result: null, - error: null, + result: undefined, + error: undefined, convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, + retryReadinessCheck: mockRetryReadinessCheck, }); render(); - expect(screen.getByText('Preparing feed')).toBeInTheDocument(); - expect( - screen.getByText('Creating the feed now. The result appears first, then preview loading continues.') - ).toBeInTheDocument(); + expect(screen.getByText('Creating feed link')).toBeInTheDocument(); + expect(screen.getByText('Checking readiness now.')).toBeInTheDocument(); }); it('clears stored token from instance info', () => { @@ -287,7 +294,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); render(); @@ -305,18 +312,18 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); render(); fireEvent.click(screen.getByRole('button', { name: 'More' })); - const utilityItems = Array.from( - screen + const utilityItems = [ + ...screen .getByLabelText('Utilities') - .querySelectorAll('.utility-strip__items > a, .utility-strip__items > button') - ).map((element) => element.textContent); + .querySelectorAll('.utility-strip__items > a, .utility-strip__items > button'), + ].map((element) => element.textContent); expect(utilityItems).toEqual([ 'Try included feeds', @@ -335,7 +342,7 @@ describe('App', () => { target: { value: 'https://example.com/articles' }, }); fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); - const accessTokenInput = document.getElementById('access-token') as HTMLInputElement; + const accessTokenInput = document.querySelector('#access-token') as HTMLInputElement; fireEvent.input(accessTokenInput, { target: { value: 'token-123' } }); fireEvent.click(screen.getByRole('button', { name: 'Save and continue' })); @@ -352,7 +359,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized')); @@ -364,7 +371,7 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); await waitFor(() => { - expect(screen.getByText('Add access token')).toBeInTheDocument(); + expect(screen.getByText('Enter access token')).toBeInTheDocument(); expect( screen.getByText('Access token was rejected. Paste a valid token to continue.') ).toBeInTheDocument(); @@ -380,7 +387,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized')); @@ -394,7 +401,7 @@ describe('App', () => { await screen.findByText('Access token was rejected. Paste a valid token to continue.'); fireEvent.click(screen.getByRole('button', { name: 'Back' })); - expect(screen.queryByText('Feed generation failed')).not.toBeInTheDocument(); + expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument(); expect(screen.queryByText('Unauthorized')).not.toBeInTheDocument(); }); @@ -406,7 +413,7 @@ describe('App', () => { }); fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); - const accessTokenInput = document.getElementById('access-token') as HTMLInputElement; + const accessTokenInput = document.querySelector('#access-token') as HTMLInputElement; fireEvent.input(accessTokenInput, { target: { value: 'token-123' } }); fireEvent.keyDown(accessTokenInput, { key: 'Enter' }); @@ -416,7 +423,7 @@ describe('App', () => { }); it('builds a bookmarklet that returns to the root app entry', () => { - window.history.replaceState({}, '', 'http://localhost:3000/'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/'); render(); fireEvent.click(screen.getByRole('button', { name: 'More' })); @@ -426,11 +433,11 @@ describe('App', () => { }); it('opens token entry immediately for bookmarklet urls when no token is saved', async () => { - window.history.replaceState({}, '', 'http://localhost:3000/?url=example.com%2Farticles'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/?url=example.com%2Farticles'); render(); - await screen.findByText('Add access token'); + await screen.findByText('Enter access token'); expect(screen.getByLabelText('Page URL')).toHaveValue('https://example.com/articles'); expect(mockConvertFeed).not.toHaveBeenCalled(); }); @@ -442,7 +449,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockConvertFeed .mockRejectedValueOnce( @@ -450,7 +457,7 @@ describe('App', () => { manualRetryStrategy: 'browserless', }) ) - .mockResolvedValueOnce(undefined); + .mockResolvedValueOnce(); render(); @@ -459,8 +466,8 @@ describe('App', () => { }); fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); - await screen.findByRole('button', { name: 'Try browserless instead' }); - fireEvent.click(screen.getByRole('button', { name: 'Try browserless instead' })); + await screen.findByRole('button', { name: 'Retry with browserless' }); + fireEvent.click(screen.getByRole('button', { name: 'Retry with browserless' })); await waitFor(() => { expect(mockConvertFeed).toHaveBeenLastCalledWith( @@ -478,7 +485,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockConvertFeed.mockRejectedValueOnce( Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { @@ -494,7 +501,7 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); await screen.findByText('Tried faraday first, then browserless. Browserless failed.'); - expect(screen.queryByRole('button', { name: /Try .* instead/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Retry with .*/ })).not.toBeInTheDocument(); }); it('does not treat non-token forbidden failures as token rejection or strategy-recovery UX', async () => { @@ -504,7 +511,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockConvertFeed.mockRejectedValueOnce( Object.assign(new Error('URL not allowed for this account'), { @@ -521,22 +528,22 @@ describe('App', () => { await screen.findByText('URL not allowed for this account'); expect(mockClearToken).not.toHaveBeenCalled(); - expect(screen.queryByText('Add access token')).not.toBeInTheDocument(); + expect(screen.queryByText('Enter access token')).not.toBeInTheDocument(); expect( screen.queryByText('Access token was rejected. Paste a valid token to continue.') ).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Try .* instead/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Retry with .*/ })).not.toBeInTheDocument(); }); it('shows the utility links in a user-focused order', () => { - window.history.replaceState({}, '', 'http://localhost:3000/#result'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/#result'); render(); fireEvent.click(screen.getByRole('button', { name: 'More' })); - const utilityLinks = Array.from( - screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a') - ).map((link) => link.textContent); + const utilityLinks = [ + ...screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a'), + ].map((link) => link.textContent); expect(utilityLinks).toEqual([ 'Try included feeds', 'Bookmarklet', @@ -558,4 +565,34 @@ describe('App', () => { 'https://hub.docker.com/r/html2rss/web' ); }); + + it('keeps OpenAPI link on the frontend origin during local development', () => { + mockUseApiMetadata.mockReturnValue({ + metadata: { + api: { + name: 'html2rss-web API', + description: 'RESTful API for converting websites to RSS feeds', + openapi_url: 'http://127.0.0.1:4000/openapi.yaml', + }, + instance: { + feed_creation: { + enabled: true, + access_token_required: true, + }, + featured_feeds: [], + }, + }, + isLoading: false, + error: undefined, + }); + + globalThis.history.replaceState({}, '', 'http://localhost:3000/'); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'More' })); + expect(screen.getByRole('link', { name: 'OpenAPI spec' })).toHaveAttribute( + 'href', + 'http://localhost:3000/openapi.yaml' + ); + }); }); diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index 818ee0f0..d830ac69 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -1,10 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; -import { h } from 'preact'; import { ResultDisplay } from '../components/ResultDisplay'; describe('ResultDisplay', () => { const mockOnCreateAnother = vi.fn(); + const mockOnRetryReadiness = vi.fn(); const mockResult = { feed: { id: 'test-id', @@ -35,10 +35,11 @@ describe('ResultDisplay', () => { publishedLabel: 'Jan 3, 2024', }, ], - error: null, + error: undefined, isLoading: false, }, - retry: null, + readinessPhase: 'feed_ready' as const, + retry: undefined, }; beforeEach(() => { @@ -46,59 +47,76 @@ describe('ResultDisplay', () => { }); it('renders the success state actions and richer preview cards', async () => { - render(); + render( + + ); - expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); + expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByText('Test Feed')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Subscribe in reader' })).toHaveAttribute( - 'href', - 'feed:https://example.com/feed.xml' - ); expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute( 'href', 'https://example.com/feed.json' ); + expect(screen.getByRole('link', { name: 'Open in feed reader' })).toHaveAttribute( + 'href', + 'feed:https://example.com/feed.xml' + ); await waitFor(() => { expect(screen.getByText('Item One')).toBeInTheDocument(); expect(screen.getByText('First preview item with markup.')).toBeInTheDocument(); - expect(screen.getAllByText('Open original')).toHaveLength(2); expect(screen.getByText(/points by canpan/i)).toBeInTheDocument(); expect(screen.getByText('Item Two')).toBeInTheDocument(); + expect(screen.getAllByText('Open original').length).toBeGreaterThan(0); expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); }); }); - it('surfaces preview failures as a result-state message', async () => { + it('surfaces feed-not-ready state with a readiness retry action', async () => { render( ); await waitFor(() => { + expect(screen.getByText('Feed still warming up')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try readiness check again' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument(); expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); }); }); - it('keeps the result state visible while preview is still loading', async () => { + it('keeps result shell visible while readiness check is in progress', async () => { render( ); await waitFor(() => { - expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); - expect(screen.getByText('Loading preview…')).toBeInTheDocument(); + expect(screen.getByText('Feed created')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Checking readiness…' })).toBeDisabled(); + expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument(); + expect(screen.getByText('Verifying feed readiness…')).toBeInTheDocument(); }); }); @@ -110,6 +128,7 @@ describe('ResultDisplay', () => { retry: { automatic: true, from: 'faraday', to: 'browserless' }, }} onCreateAnother={mockOnCreateAnother} + onRetryReadiness={mockOnRetryReadiness} /> ); @@ -121,15 +140,40 @@ describe('ResultDisplay', () => { }); it('calls onCreateAnother when the reset button is clicked', () => { - render(); + render( + + ); fireEvent.click(screen.getByRole('button', { name: 'Create another feed' })); expect(mockOnCreateAnother).toHaveBeenCalled(); }); + it('calls onRetryReadiness when the readiness action is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Try readiness check again' })); + expect(mockOnRetryReadiness).toHaveBeenCalled(); + }); + it('copies feed URL to clipboard when copy button is clicked', async () => { - render(); + render( + + ); fireEvent.click(screen.getByRole('button', { name: 'Copy feed URL' })); diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 66c108f8..4af5e168 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -5,12 +5,12 @@ import { cleanup } from '@testing-library/preact'; let server: typeof import('./mocks/server').server; // Mock window and document for tests -Object.defineProperty(window, 'matchMedia', { +Object.defineProperty(globalThis, 'matchMedia', { writable: true, value: vi.fn().mockImplementation((query) => ({ matches: false, media: query, - onchange: null, + onchange: undefined, addListener: vi.fn(), // deprecated removeListener: vi.fn(), // deprecated addEventListener: vi.fn(), @@ -29,6 +29,7 @@ const createStorageMock = () => { get length() { return store.size; }, + // eslint-disable-next-line unicorn/no-null -- Web Storage returns null for missing keys. getItem: vi.fn((key: string) => (store.has(key) ? store.get(key)! : null)), setItem: vi.fn((key: string, value: string) => { store.set(key, value); @@ -39,7 +40,8 @@ const createStorageMock = () => { clear: vi.fn(() => { store.clear(); }), - key: vi.fn((index: number) => Array.from(store.keys())[index] ?? null), + // eslint-disable-next-line unicorn/no-null -- Web Storage key() returns null for out-of-range indexes. + key: vi.fn((index: number) => [...store.keys()][index] ?? null), }, }; }; @@ -47,18 +49,16 @@ const createStorageMock = () => { const local = createStorageMock(); const session = createStorageMock(); -Object.defineProperty(window, 'localStorage', { - value: local.api, -}); Object.defineProperty(globalThis, 'localStorage', { value: local.api, + configurable: true, + writable: true, }); -Object.defineProperty(window, 'sessionStorage', { - value: session.api, -}); Object.defineProperty(globalThis, 'sessionStorage', { value: session.api, + configurable: true, + writable: true, }); beforeEach(() => { diff --git a/frontend/src/__tests__/useAccessToken.test.ts b/frontend/src/__tests__/useAccessToken.test.ts index cabf4d63..29f1f59b 100644 --- a/frontend/src/__tests__/useAccessToken.test.ts +++ b/frontend/src/__tests__/useAccessToken.test.ts @@ -4,30 +4,30 @@ import { useAccessToken } from '../hooks/useAccessToken'; describe('useAccessToken', () => { beforeEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); + globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); }); it('loads the persisted token from localStorage', async () => { - window.localStorage.setItem('html2rss_access_token', 'persisted-token'); + globalThis.localStorage.setItem('html2rss_access_token', 'persisted-token'); const { result } = renderHook(() => useAccessToken()); expect(result.current.isLoading).toBe(false); expect(result.current.token).toBe('persisted-token'); expect(result.current.hasToken).toBe(true); - expect(result.current.error).toBeNull(); + expect(result.current.error).toBeUndefined(); }); it('migrates a legacy session token into localStorage', async () => { - window.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); + globalThis.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); const { result } = renderHook(() => useAccessToken()); expect(result.current.isLoading).toBe(false); expect(result.current.token).toBe('legacy-token'); - expect(window.localStorage.getItem('html2rss_access_token')).toBe('legacy-token'); - expect(window.sessionStorage.getItem('html2rss_access_token')).toBeNull(); + expect(globalThis.localStorage.getItem('html2rss_access_token')).toBe('legacy-token'); + expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); }); it('saves new tokens to the persistent storage path', async () => { @@ -39,13 +39,13 @@ describe('useAccessToken', () => { expect(result.current.token).toBe('new-token'); expect(result.current.hasToken).toBe(true); - expect(window.localStorage.getItem('html2rss_access_token')).toBe('new-token'); - expect(window.sessionStorage.getItem('html2rss_access_token')).toBeNull(); + expect(globalThis.localStorage.getItem('html2rss_access_token')).toBe('new-token'); + expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); }); it('clears both persistent and legacy token copies', async () => { - window.localStorage.setItem('html2rss_access_token', 'persisted-token'); - window.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); + globalThis.localStorage.setItem('html2rss_access_token', 'persisted-token'); + globalThis.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); const { result } = renderHook(() => useAccessToken()); @@ -53,9 +53,9 @@ describe('useAccessToken', () => { result.current.clearToken(); }); - expect(result.current.token).toBeNull(); + expect(result.current.token).toBeUndefined(); expect(result.current.hasToken).toBe(false); - expect(window.localStorage.getItem('html2rss_access_token')).toBeNull(); - expect(window.sessionStorage.getItem('html2rss_access_token')).toBeNull(); + expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull(); + expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); }); }); diff --git a/frontend/src/__tests__/useAuth.test.ts b/frontend/src/__tests__/useAuth.test.ts index 5995ff27..02ea4b2d 100644 --- a/frontend/src/__tests__/useAuth.test.ts +++ b/frontend/src/__tests__/useAuth.test.ts @@ -27,12 +27,12 @@ describe('useAuth', () => { beforeEach(() => { localStorageMock = createStorageMock(); sessionStorageMock = createStorageMock(); - Object.defineProperty(window, 'localStorage', { + Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true, writable: true, }); - Object.defineProperty(window, 'sessionStorage', { + Object.defineProperty(globalThis, 'sessionStorage', { value: sessionStorageMock, configurable: true, writable: true, @@ -41,13 +41,13 @@ describe('useAuth', () => { }); it('should initialize with unauthenticated state', () => { - localStorageMock.getItem.mockReturnValue(null); + localStorageMock.getItem.mockReturnValue(); const { result } = renderHook(() => useAuth()); expect(result.current.isAuthenticated).toBe(false); - expect(result.current.username).toBeNull(); - expect(result.current.token).toBeNull(); + expect(result.current.username).toBeUndefined(); + expect(result.current.token).toBeUndefined(); }); it('should load auth state from sessionStorage on mount', () => { @@ -65,7 +65,7 @@ describe('useAuth', () => { }); it('should login and store credentials', async () => { - localStorageMock.getItem.mockReturnValue(null); + localStorageMock.getItem.mockReturnValue(); const { result } = renderHook(() => useAuth()); @@ -90,8 +90,8 @@ describe('useAuth', () => { }); expect(result.current.isAuthenticated).toBe(false); - expect(result.current.username).toBeNull(); - expect(result.current.token).toBeNull(); + expect(result.current.username).toBeUndefined(); + expect(result.current.token).toBeUndefined(); expect(localStorageMock.removeItem).toHaveBeenCalledWith('html2rss_username'); expect(localStorageMock.removeItem).toHaveBeenCalledWith('html2rss_token'); }); diff --git a/frontend/src/__tests__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts index 33cb19dc..ac13df99 100644 --- a/frontend/src/__tests__/useFeedConversion.contract.test.ts +++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts @@ -6,7 +6,7 @@ import { useFeedConversion } from '../hooks/useFeedConversion'; describe('useFeedConversion contract', () => { it('sends feed creation request with bearer token', async () => { - let receivedAuthorization: string | null = null; + let receivedAuthorization: string | undefined; server.use( http.post('/api/v1/feeds', async ({ request }) => { @@ -48,12 +48,14 @@ describe('useFeedConversion contract', () => { }); expect(receivedAuthorization).toBe('Bearer test-token-123'); - expect(result.current.error).toBeNull(); + expect(result.current.error).toBeUndefined(); expect(result.current.result?.feed.feed_token).toBe('generated-token'); expect(result.current.result?.feed.public_url).toBe('/api/v1/feeds/generated-token'); expect(result.current.result?.feed.json_public_url).toBe('/api/v1/feeds/generated-token.json'); + expect(result.current.result?.readinessPhase).toBe('link_created'); await waitFor(() => { - expect(result.current.result?.preview.error).toBeNull(); + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + expect(result.current.result?.preview.error).toBeUndefined(); expect(result.current.result?.preview.isLoading).toBe(false); expect(result.current.result?.preview.items).toHaveLength(1); }); @@ -77,7 +79,7 @@ describe('useFeedConversion contract', () => { ).rejects.toThrow('URL parameter is required'); }); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('URL parameter is required'); }); @@ -99,11 +101,11 @@ describe('useFeedConversion contract', () => { ).rejects.toThrow('Invalid response format from feed creation API'); }); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Invalid response format from feed creation API'); }); - it('preserves the created feed when preview loading fails', async () => { + it('marks the feed as not-ready-yet when preview endpoint keeps returning 5xx', async () => { server.use( http.post('/api/v1/feeds', async () => HttpResponse.json( @@ -115,7 +117,7 @@ describe('useFeedConversion contract', () => { { status: 201 } ) ), - http.get('/api/v1/feeds/generated-token.json', async () => new HttpResponse(null, { status: 502 })) + http.get('/api/v1/feeds/generated-token.json', async () => new HttpResponse(undefined, { status: 502 })) ); const { result } = renderHook(() => useFeedConversion()); @@ -124,12 +126,18 @@ describe('useFeedConversion contract', () => { await result.current.convertFeed('https://example.com/articles', 'faraday', 'token'); }); - expect(result.current.error).toBeNull(); + expect(result.current.error).toBeUndefined(); expect(result.current.result?.feed.feed_token).toBe('generated-token'); - await waitFor(() => { - expect(result.current.result?.preview.items).toEqual([]); - expect(result.current.result?.preview.error).toBe('Preview unavailable right now.'); - expect(result.current.result?.preview.isLoading).toBe(false); - }); + await waitFor( + () => { + expect(result.current.result?.readinessPhase).toBe('feed_not_ready_yet'); + expect(result.current.result?.preview.items).toEqual([]); + expect(result.current.result?.preview.error).toBe( + 'Feed is still preparing. Try again in a few seconds.' + ); + expect(result.current.result?.preview.isLoading).toBe(false); + }, + { timeout: 6000 } + ); }); }); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index a0082a77..7f55096d 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -2,12 +2,22 @@ import { describe, it, expect, beforeEach, afterEach, vi, type SpyInstance } fro import { renderHook, act, waitFor } from '@testing-library/preact'; import { useFeedConversion } from '../hooks/useFeedConversion'; +const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; +const SHORT_SETTLE_MS = 50; +const FULL_SETTLE_MS = 100; + +const sumDelays = (delays: readonly number[]) => delays.reduce((total, delay) => total + delay, 0); + +const advanceAfterRetries = async (delays: readonly number[], settleMs: number) => { + await vi.advanceTimersByTimeAsync(sumDelays(delays) + settleMs); +}; + describe('useFeedConversion', () => { let fetchMock: SpyInstance; beforeEach(() => { vi.clearAllMocks(); - fetchMock = vi.spyOn(global, 'fetch'); + fetchMock = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { @@ -18,8 +28,8 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeNull(); - expect(result.current.error).toBeNull(); + expect(result.current.result).toBeUndefined(); + expect(result.current.error).toBeUndefined(); }); it('should handle successful conversion', async () => { @@ -78,10 +88,11 @@ describe('useFeedConversion', () => { feed: mockFeed, preview: { items: [], - error: null, + error: undefined, isLoading: true, }, - retry: null, + readinessPhase: 'link_created', + retry: undefined, }); await waitFor(() => { expect(result.current.result).toEqual({ @@ -95,13 +106,14 @@ describe('useFeedConversion', () => { url: 'https://example.com/item', }, ], - error: null, + error: undefined, isLoading: false, }, - retry: null, + readinessPhase: 'feed_ready', + retry: undefined, }); }); - expect(result.current.error).toBeNull(); + expect(result.current.error).toBeUndefined(); expect(fetchMock).toHaveBeenCalledTimes(2); }); @@ -128,7 +140,7 @@ describe('useFeedConversion', () => { }); expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toContain('Bad Request'); }); @@ -144,71 +156,79 @@ describe('useFeedConversion', () => { }); expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Network error'); }); it('preserves the created feed when preview loading fails after feed creation', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { - feed: createdFeed, - }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - fetchMock.mockResolvedValueOnce(new Response('nope', { status: 502 })); + vi.useFakeTimers(); + try { + const createdFeed = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com', + strategy: 'faraday', + feed_token: 'test-token', + public_url: 'https://example.com/feed', + json_public_url: 'https://example.com/feed.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { + feed: createdFeed, + }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + fetchMock.mockResolvedValue(new Response('nope', { status: 502 })); - const { result } = renderHook(() => useFeedConversion()); - let conversionResult: Awaited> | undefined; + const { result } = renderHook(() => useFeedConversion()); + let conversionResult: Awaited> | undefined; - await act(async () => { - conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - }); + await act(async () => { + conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS, FULL_SETTLE_MS); + }); - expect(result.current.isConverting).toBe(false); - expect(conversionResult).toEqual({ - feed: createdFeed, - preview: { - items: [], - error: null, - isLoading: true, - }, - retry: null, - }); - await waitFor(() => { - expect(result.current.result).toEqual({ + expect(result.current.isConverting).toBe(false); + expect(conversionResult).toEqual({ feed: createdFeed, preview: { items: [], - error: 'Preview unavailable right now.', - isLoading: false, + error: undefined, + isLoading: true, }, - retry: null, + readinessPhase: 'link_created', + retry: undefined, }); - }); - expect(result.current.error).toBeNull(); + await waitFor(() => { + expect(result.current.result).toEqual({ + feed: createdFeed, + preview: { + items: [], + error: 'Feed is still preparing. Try again in a few seconds.', + isLoading: false, + }, + readinessPhase: 'feed_not_ready_yet', + retry: undefined, + }); + }); + expect(result.current.error).toBeUndefined(); + } finally { + vi.useRealTimers(); + } }); - it('publishes the result before preview loading finishes', async () => { + it('publishes link_created before readiness is confirmed', async () => { const createdFeed = { id: 'test-id', name: 'Test Feed', @@ -221,7 +241,7 @@ describe('useFeedConversion', () => { updated_at: '2024-01-01T00:00:00Z', }; - let resolvePreviewResponse: ((value: Response) => void) | null = null; + let resolvePreviewResponse: ((value: Response) => void) | undefined; const previewResponse = new Promise((resolve) => { resolvePreviewResponse = resolve; }); @@ -251,10 +271,11 @@ describe('useFeedConversion', () => { feed: createdFeed, preview: { items: [], - error: null, + error: undefined, isLoading: true, }, - retry: null, + readinessPhase: 'link_created', + retry: undefined, }); expect(result.current.isConverting).toBe(false); expect(result.current.result).toEqual(conversionResult); @@ -288,9 +309,165 @@ describe('useFeedConversion', () => { url: 'https://example.com/item', }, ], - error: null, + error: undefined, isLoading: false, }); + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + }); + }); + + it('retries readiness checks after transient preview failures and eventually becomes ready', async () => { + vi.useFakeTimers(); + try { + const createdFeed = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com', + strategy: 'faraday', + feed_token: 'test-token', + public_url: 'https://example.com/feed', + json_public_url: 'https://example.com/feed.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { feed: createdFeed }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockResolvedValueOnce(new Response('temporary-failure', { status: 500 })) + .mockResolvedValueOnce(new Response('still-warming-up', { status: 503 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + title: 'Recovered item', + content_text: 'Recovered preview excerpt', + url: 'https://example.com/item', + date_published: '2024-01-02T00:00:00Z', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/feed+json' }, + } + ) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS.slice(0, 2), SHORT_SETTLE_MS); + }); + + await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + expect(result.current.result?.preview.items[0]?.title).toBe('Recovered item'); + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + } finally { + vi.useRealTimers(); + } + }); + + it('stops readiness retries after the configured limit and marks feed_not_ready_yet', async () => { + vi.useFakeTimers(); + try { + const createdFeed = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com', + strategy: 'faraday', + feed_token: 'test-token', + public_url: 'https://example.com/feed', + json_public_url: 'https://example.com/feed.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { feed: createdFeed }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockResolvedValue(new Response('temporary-failure', { status: 500 })); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS, FULL_SETTLE_MS); + }); + + await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('feed_not_ready_yet'); + expect(result.current.result?.preview.error).toBe( + 'Feed is still preparing. Try again in a few seconds.' + ); + }); + expect(fetchMock).toHaveBeenCalledTimes(6); + } finally { + vi.useRealTimers(); + } + }); + + it('marks preview_unavailable for non-retryable preview responses', async () => { + const createdFeed = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com', + strategy: 'faraday', + feed_token: 'test-token', + public_url: 'https://example.com/feed', + json_public_url: 'https://example.com/feed.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { feed: createdFeed }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + }); + + await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('preview_unavailable'); + expect(result.current.result?.preview.error).toBe('Preview unavailable right now.'); }); }); @@ -435,7 +612,7 @@ describe('useFeedConversion', () => { }); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Unauthorized'); }); @@ -462,7 +639,7 @@ describe('useFeedConversion', () => { }); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Input rejected'); }); @@ -576,7 +753,7 @@ describe('useFeedConversion', () => { 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed' ); expect(thrownError?.manualRetryStrategy).toBeUndefined(); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe( 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed' ); @@ -606,11 +783,11 @@ describe('useFeedConversion', () => { updated_at: '2024-01-01T00:00:00Z', }; - let resolvePreviewA: ((value: Response) => void) | null = null; + let resolvePreviewA: ((value: Response) => void) | undefined; const previewAPromise = new Promise((resolve) => { resolvePreviewA = resolve; }); - let resolvePreviewB: ((value: Response) => void) | null = null; + let resolvePreviewB: ((value: Response) => void) | undefined; const previewBPromise = new Promise((resolve) => { resolvePreviewB = resolve; }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3e8bd3f3..267ebea4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,9 +1,9 @@ import { createClient, createConfig } from './generated/client'; const resolveBaseUrl = (): string => { - if (typeof window === 'undefined') return 'http://localhost/api/v1'; + if (globalThis.window === undefined) return 'http://localhost/api/v1'; - const origin = window.location?.origin; + const origin = globalThis.location?.origin; if (!origin || origin === 'null') return 'http://localhost/api/v1'; return `${origin}/api/v1`; @@ -15,5 +15,5 @@ export const apiClient = createClient( }) ); -export const bearerHeaders = (token: string | null): Record => +export const bearerHeaders = (token?: string): Record => token ? { Authorization: `Bearer ${token}` } : {}; diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index 041154d4..d4fed873 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -11,10 +11,12 @@ export interface FeedPreviewItem { export interface FeedPreviewState { items: FeedPreviewItem[]; - error: string | null; + error?: string; isLoading: boolean; } +export type FeedReadinessPhase = 'link_created' | 'feed_ready' | 'feed_not_ready_yet' | 'preview_unavailable'; + export interface FeedRetryState { automatic: boolean; from: string; @@ -24,7 +26,8 @@ export interface FeedRetryState { export interface CreatedFeedResult { feed: FeedRecord; preview: FeedPreviewState; - retry: FeedRetryState | null; + readinessPhase: FeedReadinessPhase; + retry?: FeedRetryState; } export interface ApiMetadataRecord { diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 981da692..b1adfcaf 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -12,6 +12,47 @@ const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true }; const preferredStrategy = (strategies: { id: string }[]) => strategies.find((strategy) => strategy.id === 'faraday')?.id ?? strategies[0]?.id; +function strategyHint(strategy: Strategy) { + if (strategy.id === 'faraday') return 'Best for most pages.'; + if (strategy.id === 'browserless') return 'Use when the page needs JavaScript to load content.'; + return strategy.name; +} + +function isAccessTokenError(message: string) { + const normalized = message.toLowerCase(); + const mentionsAuthToken = + normalized.includes('access token') || + normalized.includes('token') || + normalized.includes('authentication') || + normalized.includes('bearer'); + + return ( + normalized.includes('unauthorized') || + normalized.includes('invalid token') || + normalized.includes('token rejected') || + normalized.includes('authentication') || + (normalized.includes('forbidden') && mentionsAuthToken) + ); +} + +function isActionableStrategySwitch(message: string, currentStrategy: string, retryStrategy: string) { + if (currentStrategy !== 'faraday' || retryStrategy !== 'browserless') return false; + + const normalized = message.toLowerCase(); + return !( + normalized.includes('unauthorized') || + normalized.includes('forbidden') || + normalized.includes('not allowed') || + normalized.includes('disabled') || + normalized.includes('access token') || + normalized.includes('token') || + normalized.includes('authentication') || + normalized.includes('bad request') || + normalized.includes('url') || + normalized.includes('unsupported strategy') + ); +} + interface ConversionErrorWithMeta extends Error { manualRetryStrategy?: string; } @@ -46,6 +87,7 @@ export function App() { convertFeed, clearError, clearResult, + retryReadinessCheck, } = useFeedConversion(); const { strategies, isLoading: strategiesLoading, error: strategiesError } = useStrategies(); @@ -56,19 +98,19 @@ export function App() { const [tokenError, setTokenError] = useState(''); const [manualRetryStrategy, setManualRetryStrategy] = useState(''); const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0); - const autoSubmitUrlRef = useRef(null); - const hasAutoSubmittedRef = useRef(false); + const autoSubmitUrlReference = useRef(undefined); + const hasAutoSubmittedReference = useRef(false); const selectedStrategy = feedFormData.strategy || preferredStrategy(strategies) || ''; useEffect(() => { - if (typeof window === 'undefined') return; + if (globalThis.window === undefined) return; - const urlParam = new URLSearchParams(window.location.search).get('url'); - if (!urlParam) return; - autoSubmitUrlRef.current = urlParam; + const urlParameter = new URLSearchParams(globalThis.location.search).get('url'); + if (!urlParameter) return; + autoSubmitUrlReference.current = urlParameter; if (feedFormData.url) return; - setFeedFormData((prev) => ({ ...prev, url: urlParam })); + setFeedFormData((previous) => ({ ...previous, url: urlParameter })); }, [feedFormData.url]); useEffect(() => { @@ -76,7 +118,7 @@ export function App() { if (!nextStrategy) return; const hasCurrentStrategy = strategies.some((strategy) => strategy.id === feedFormData.strategy); - if (!hasCurrentStrategy) setFeedFormData((prev) => ({ ...prev, strategy: nextStrategy })); + if (!hasCurrentStrategy) setFeedFormData((previous) => ({ ...previous, strategy: nextStrategy })); }, [strategies, feedFormData.strategy]); const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; @@ -84,57 +126,16 @@ export function App() { const submitDisabled = isConverting || strategiesLoading || !feedCreation.enabled || showTokenPrompt; const setFeedField = (key: 'url' | 'strategy', value: string) => { - setFeedFormData((prev) => ({ ...prev, [key]: value })); - setFeedFieldErrors((prev) => ({ - ...prev, - url: key === 'url' ? '' : prev.url, + setFeedFormData((previous) => ({ ...previous, [key]: value })); + setFeedFieldErrors((previous) => ({ + ...previous, + url: key === 'url' ? '' : previous.url, form: '', })); setManualRetryStrategy(''); clearError(); }; - const strategyHint = (strategy: Strategy) => { - if (strategy.id === 'faraday') return 'Start here for most pages.'; - if (strategy.id === 'browserless') return 'Use this if the page loads content with JavaScript.'; - return strategy.name; - }; - - const isAccessTokenError = (message: string) => { - const normalized = message.toLowerCase(); - const mentionsAuthToken = - normalized.includes('access token') || - normalized.includes('token') || - normalized.includes('authentication') || - normalized.includes('bearer'); - - return ( - normalized.includes('unauthorized') || - normalized.includes('invalid token') || - normalized.includes('token rejected') || - normalized.includes('authentication') || - (normalized.includes('forbidden') && mentionsAuthToken) - ); - }; - - const isActionableStrategySwitch = (message: string, currentStrategy: string, retryStrategy: string) => { - if (currentStrategy !== 'faraday' || retryStrategy !== 'browserless') return false; - - const normalized = message.toLowerCase(); - return !( - normalized.includes('unauthorized') || - normalized.includes('forbidden') || - normalized.includes('not allowed') || - normalized.includes('disabled') || - normalized.includes('access token') || - normalized.includes('token') || - normalized.includes('authentication') || - normalized.includes('bad request') || - normalized.includes('url') || - normalized.includes('unsupported strategy') - ); - }; - const attemptFeedCreation = async (accessToken: string, strategyOverride?: string) => { const strategy = strategyOverride || selectedStrategy; const normalizedUrl = normalizeUserUrl(feedFormData.url); @@ -152,13 +153,13 @@ export function App() { if (!feedCreation.enabled) { setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, - form: 'Custom feed generation is disabled for this instance.', + form: 'Feed creation is disabled on this instance.', }); return false; } if (feedCreation.access_token_required && !accessToken) { - setFeedFormData((prev) => ({ ...prev, url: normalizedUrl })); + setFeedFormData((previous) => ({ ...previous, url: normalizedUrl })); clearError(); setShowTokenPrompt(true); setTokenError(''); @@ -166,7 +167,7 @@ export function App() { } try { - setFeedFormData((prev) => ({ ...prev, url: normalizedUrl })); + setFeedFormData((previous) => ({ ...previous, url: normalizedUrl })); await convertFeed(normalizedUrl, strategy, accessToken); setShowTokenPrompt(false); setTokenError(''); @@ -225,27 +226,27 @@ export function App() { const handleRetryWithStrategy = () => { if (!manualRetryStrategy) return; - setFeedFormData((prev) => ({ ...prev, strategy: manualRetryStrategy })); + setFeedFormData((previous) => ({ ...previous, strategy: manualRetryStrategy })); setFeedFieldErrors(EMPTY_FEED_ERRORS); clearError(); void attemptFeedCreation(token ?? '', manualRetryStrategy); }; useEffect(() => { - const autoSubmitUrl = autoSubmitUrlRef.current; - if (!autoSubmitUrl || hasAutoSubmittedRef.current) return; + const autoSubmitUrl = autoSubmitUrlReference.current; + if (!autoSubmitUrl || hasAutoSubmittedReference.current) return; if (strategiesLoading || metadataLoading || tokenLoading) return; if (feedFormData.url !== autoSubmitUrl || !selectedStrategy) return; if (feedCreation.access_token_required && !token) { - hasAutoSubmittedRef.current = true; - setFeedFormData((prev) => ({ ...prev, url: normalizeUserUrl(autoSubmitUrl) })); + hasAutoSubmittedReference.current = true; + setFeedFormData((previous) => ({ ...previous, url: normalizeUserUrl(autoSubmitUrl) })); setShowTokenPrompt(true); setTokenError(''); return; } - hasAutoSubmittedRef.current = true; + hasAutoSubmittedReference.current = true; setFeedFieldErrors(EMPTY_FEED_ERRORS); void attemptFeedCreation(token ?? ''); }, [ @@ -287,7 +288,11 @@ export function App() { )} {result ? ( - + ) : ( <>