Skip to content

Commit 07304bc

Browse files
committed
Simplify create UX with auto strategy and resilient retry recovery
1 parent f3f28eb commit 07304bc

8 files changed

Lines changed: 327 additions & 262 deletions

File tree

frontend/src/__tests__/App.contract.test.tsx

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -59,32 +59,29 @@ describe('App contract', () => {
5959

6060
render(<App />);
6161

62-
await screen.findByLabelText('Page URL');
6362
await waitFor(() => {
64-
expect(screen.getByRole('combobox')).toHaveValue('faraday');
63+
expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeEnabled();
6564
});
65+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
6666

6767
const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement;
6868
fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } });
6969

7070
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
7171

72-
await waitFor(() => {
73-
expect(screen.getByText('Feed ready')).toBeInTheDocument();
74-
expect(screen.getByText('Example Feed')).toBeInTheDocument();
75-
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'ready');
76-
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
77-
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
78-
expect(screen.getByRole('link', { name: 'Open feed' })).toHaveClass('btn--primary');
79-
expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute(
80-
'href',
81-
'http://localhost:3000/api/v1/feeds/generated-token.json'
82-
);
83-
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
84-
expect(screen.getByText('Preview')).toBeInTheDocument();
85-
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
86-
expect(screen.getByText('Contract Item')).toBeInTheDocument();
87-
});
72+
await waitFor(
73+
() => {
74+
expect(screen.getByText('Feed created')).toBeInTheDocument();
75+
expect(screen.getByText('Example Feed')).toBeInTheDocument();
76+
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'warming');
77+
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
78+
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
79+
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
80+
expect(screen.getByText('Preview')).toBeInTheDocument();
81+
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
82+
},
83+
{ timeout: 3000 }
84+
);
8885
});
8986

9087
it('loads instance metadata from /api/v1 without trailing slash', async () => {
@@ -154,10 +151,10 @@ describe('App contract', () => {
154151

155152
render(<App />);
156153

157-
await screen.findByLabelText('Page URL');
158154
await waitFor(() => {
159-
expect(screen.getByRole('combobox')).toHaveValue('faraday');
155+
expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeEnabled();
160156
});
157+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
161158

162159
fireEvent.input(screen.getByLabelText('Page URL'), {
163160
target: { value: 'https://example.com/articles' },
@@ -167,7 +164,7 @@ describe('App contract', () => {
167164
await screen.findByText('Access token was rejected. Paste a valid token to continue.');
168165

169166
expect(screen.getByText('Enter access token')).toBeInTheDocument();
170-
expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument();
167+
expect(screen.queryByText("Couldn't create feed yet")).not.toBeInTheDocument();
171168
expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull();
172169
});
173170
});

frontend/src/__tests__/App.test.tsx

Lines changed: 77 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,9 @@ describe('App', () => {
116116
expect(screen.getByLabelText('html2rss')).toBeInTheDocument();
117117
expect(screen.getByRole('link', { name: 'html2rss' })).toHaveAttribute('href', '/create');
118118
expect(screen.getByLabelText('Page URL')).toBeInTheDocument();
119-
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
120-
expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument();
119+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
120+
expect(screen.getByLabelText('Utilities')).toBeInTheDocument();
121+
expect(screen.getByRole('link', { name: 'Bookmarklet' })).toBeInTheDocument();
121122
expect(document.querySelector('.form-shell')).toHaveAttribute('data-state', 'idle');
122123
});
123124

@@ -139,25 +140,58 @@ describe('App', () => {
139140
});
140141
});
141142

142-
it('prefers faraday as the default strategy when available', () => {
143+
it('submits create requests with the faraday default strategy', async () => {
144+
mockUseAccessToken.mockReturnValue({
145+
token: 'saved-token',
146+
hasToken: true,
147+
saveToken: mockSaveToken,
148+
clearToken: mockClearToken,
149+
isLoading: false,
150+
error: undefined,
151+
});
152+
143153
render(<App />);
144154

145-
return waitFor(() => {
146-
expect(screen.getByRole('combobox')).toHaveValue('faraday');
155+
fireEvent.input(screen.getByLabelText('Page URL'), {
156+
target: { value: 'https://example.com/articles' },
157+
});
158+
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
159+
160+
await waitFor(() => {
161+
expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'faraday', 'saved-token');
147162
});
148163
});
149164

150-
it('falls back to the first available strategy when browserless is unavailable', () => {
165+
it('falls back to the first available strategy when faraday is unavailable', async () => {
151166
mockUseStrategies.mockReturnValue({
152-
strategies: [{ id: 'faraday', name: 'faraday', display_name: 'Default' }],
167+
strategies: [
168+
{ id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' },
169+
],
170+
isLoading: false,
171+
error: undefined,
172+
});
173+
mockUseAccessToken.mockReturnValue({
174+
token: 'saved-token',
175+
hasToken: true,
176+
saveToken: mockSaveToken,
177+
clearToken: mockClearToken,
153178
isLoading: false,
154179
error: undefined,
155180
});
156181

157182
render(<App />);
158183

159-
return waitFor(() => {
160-
expect(screen.getByRole('combobox')).toHaveValue('faraday');
184+
fireEvent.input(screen.getByLabelText('Page URL'), {
185+
target: { value: 'https://example.com/articles' },
186+
});
187+
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
188+
189+
await waitFor(() => {
190+
expect(mockConvertFeed).toHaveBeenCalledWith(
191+
'https://example.com/articles',
192+
'browserless',
193+
'saved-token'
194+
);
161195
});
162196
});
163197

@@ -245,8 +279,8 @@ describe('App', () => {
245279
expect(globalThis.location.pathname).toBe('/token');
246280
expect(document.querySelector('.form-shell')).toHaveAttribute('data-state', 'token_required');
247281
expect(screen.getByLabelText('Page URL')).toBeDisabled();
248-
expect(screen.getByRole('combobox')).toBeDisabled();
249-
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
282+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
283+
expect(screen.queryByLabelText('Utilities')).not.toBeInTheDocument();
250284
expect(screen.getByRole('link', { name: 'Set up your own instance with Docker.' })).toBeInTheDocument();
251285
expect(screen.getByText('Required by this instance.')).toBeInTheDocument();
252286
expect(screen.queryByText('Paste an access token to keep going.')).not.toBeInTheDocument();
@@ -326,7 +360,7 @@ describe('App', () => {
326360

327361
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'failed');
328362
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
329-
expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument();
363+
expect(screen.getByRole('link', { name: 'Bookmarklet' })).toBeInTheDocument();
330364
expect(screen.getByText('Example Feed')).toBeInTheDocument();
331365
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();
332366

@@ -350,7 +384,7 @@ describe('App', () => {
350384

351385
render(<App />);
352386

353-
expect(screen.getByText('Could not create feed link')).toBeInTheDocument();
387+
expect(screen.getByText("Couldn't create feed yet")).toBeInTheDocument();
354388
expect(screen.getByText('Access denied')).toBeInTheDocument();
355389
});
356390

@@ -384,7 +418,6 @@ describe('App', () => {
384418

385419
render(<App />);
386420

387-
fireEvent.click(screen.getByRole('button', { name: 'More' }));
388421
fireEvent.click(screen.getByRole('button', { name: 'Clear saved token' }));
389422

390423
expect(mockClearToken).toHaveBeenCalled();
@@ -402,8 +435,6 @@ describe('App', () => {
402435

403436
render(<App />);
404437

405-
fireEvent.click(screen.getByRole('button', { name: 'More' }));
406-
407438
const utilityItems = [
408439
...screen
409440
.getByLabelText('Utilities')
@@ -490,7 +521,7 @@ describe('App', () => {
490521
await waitFor(() => {
491522
expect(globalThis.location.pathname).toBe('/create');
492523
});
493-
expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument();
524+
expect(screen.queryByText("Couldn't create feed yet")).not.toBeInTheDocument();
494525
expect(screen.queryByText('Unauthorized')).not.toBeInTheDocument();
495526
});
496527

@@ -515,7 +546,6 @@ describe('App', () => {
515546
globalThis.history.replaceState({}, '', 'http://localhost:3000/create');
516547
render(<App />);
517548

518-
fireEvent.click(screen.getByRole('button', { name: 'More' }));
519549
const bookmarklet = screen.getByRole('link', { name: 'Bookmarklet' });
520550
expect(bookmarklet.getAttribute('href')).toContain('/?url=');
521551
expect(bookmarklet.getAttribute('href')).not.toContain('%27+encodeURIComponent');
@@ -568,7 +598,7 @@ describe('App', () => {
568598
});
569599
});
570600

571-
it('does not offer a duplicate retry action after automatic fallback already failed', async () => {
601+
it('shows Try again after automatic fallback already failed and reruns the create flow', async () => {
572602
mockUseAccessToken.mockReturnValue({
573603
token: 'saved-token',
574604
hasToken: true,
@@ -577,11 +607,13 @@ describe('App', () => {
577607
isLoading: false,
578608
error: undefined,
579609
});
580-
mockConvertFeed.mockRejectedValueOnce(
581-
Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), {
582-
manualRetryStrategy: '',
583-
})
584-
);
610+
mockConvertFeed
611+
.mockRejectedValueOnce(
612+
Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), {
613+
manualRetryStrategy: '',
614+
})
615+
)
616+
.mockResolvedValueOnce(mockCreatedFeedResult);
585617

586618
render(<App />);
587619

@@ -590,8 +622,17 @@ describe('App', () => {
590622
});
591623
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
592624

593-
await screen.findByText('Tried faraday first, then browserless. Browserless failed.');
594-
expect(screen.queryByRole('button', { name: /Retry with .*/ })).not.toBeInTheDocument();
625+
await screen.findByRole('button', { name: 'Try again' });
626+
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
627+
628+
await waitFor(() => {
629+
expect(mockConvertFeed).toHaveBeenCalledTimes(2);
630+
expect(mockConvertFeed).toHaveBeenLastCalledWith(
631+
'https://example.com/articles',
632+
'faraday',
633+
'saved-token'
634+
);
635+
});
595636
});
596637

597638
it('does not treat non-token forbidden failures as token rejection or strategy-recovery UX', async () => {
@@ -622,15 +663,14 @@ describe('App', () => {
622663
expect(
623664
screen.queryByText('Access token was rejected. Paste a valid token to continue.')
624665
).not.toBeInTheDocument();
666+
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
625667
expect(screen.queryByRole('button', { name: /Retry with .*/ })).not.toBeInTheDocument();
626668
});
627669

628670
it('shows the utility links in a user-focused order', () => {
629671
globalThis.history.replaceState({}, '', 'http://localhost:3000/create');
630672
render(<App />);
631673

632-
fireEvent.click(screen.getByRole('button', { name: 'More' }));
633-
634674
const utilityLinks = [
635675
...screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a'),
636676
].map((link) => link.textContent);
@@ -679,10 +719,18 @@ describe('App', () => {
679719
globalThis.history.replaceState({}, '', 'http://localhost:3000/create');
680720
render(<App />);
681721

682-
fireEvent.click(screen.getByRole('button', { name: 'More' }));
683722
expect(screen.getByRole('link', { name: 'OpenAPI spec' })).toHaveAttribute(
684723
'href',
685724
'http://localhost:3000/openapi.yaml'
686725
);
687726
});
727+
728+
it('shows footer utilities on result routes', async () => {
729+
globalThis.history.replaceState({}, '', 'http://localhost:3000/result/generated-token');
730+
render(<App />);
731+
732+
await waitFor(() => {
733+
expect(screen.getByLabelText('Utilities')).toBeInTheDocument();
734+
});
735+
});
688736
});

frontend/src/__tests__/feedSessionStorage.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,34 @@ describe('feedSessionStorage', () => {
1515
globalThis.sessionStorage.clear();
1616
});
1717

18-
it('persists and hydrates the create draft state', () => {
18+
it('persists and hydrates the create draft state from the url only', () => {
1919
saveFeedDraftState({ url: 'https://example.com/articles', strategy: 'faraday' });
2020

2121
expect(loadFeedDraftState()).toEqual({
2222
url: 'https://example.com/articles',
23-
strategy: 'faraday',
2423
});
24+
expect(globalThis.localStorage.getItem('html2rss_feed_draft_state')).toBe(
25+
JSON.stringify({ url: 'https://example.com/articles' })
26+
);
2527

2628
clearFeedDraftState();
2729
expect(loadFeedDraftState()).toBeUndefined();
2830
});
2931

32+
it('hydrates legacy draft state that still includes strategy data', () => {
33+
globalThis.localStorage.setItem(
34+
'html2rss_feed_draft_state',
35+
JSON.stringify({
36+
url: 'https://example.com/articles',
37+
strategy: 'faraday',
38+
})
39+
);
40+
41+
expect(loadFeedDraftState()).toEqual({
42+
url: 'https://example.com/articles',
43+
});
44+
});
45+
3046
it('persists and hydrates the latest feed result snapshot by token', () => {
3147
const result = {
3248
feed: {

0 commit comments

Comments
 (0)