Skip to content

Commit 2d1b71a

Browse files
authored
feat: add routed frontend feed creation workflow (#963)
## Summary - add hash-routed create/token/result workflow for the frontend - remove strategy-era UI/hooks and move preview checks into frontend-owned feed workflow state - persist create draft state and harden access-token handling/navigation recovery --- This pull request refactors the frontend app's test suite to reflect recent UI and workflow changes, focusing on a simplified "radical-simple" feed creation flow. The strategy selection UI and logic have been removed, token handling has moved to `sessionStorage`, and utility actions have been consolidated. Test cases and mocks have been updated to match these changes, and error handling and workflow state assertions have been improved. The most important changes are: **Feed Creation Flow & Strategy Handling:** - Removed all references to strategy selection from tests, including the strategy combobox, related assertions, and mocks. Feed creation now only requires a URL and (optionally) a token, and tests confirm that strategy state is not persisted or exposed in the UI. [[1]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L78-R105) [[2]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L119-R148) [[3]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L159-R179) [[4]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14R189-R193) [[5]](diffhunk://#diff-0f9368690552ac79a208dde6e3f09cf9f4e350e910d0de10c35dc2cc235479b1R77-R137) **Token Management & Authentication:** - Updated tests to use `sessionStorage` instead of `localStorage` for access tokens, and ensured that token rejection and recovery flows work correctly with structured error responses. [[1]](diffhunk://#diff-0f9368690552ac79a208dde6e3f09cf9f4e350e910d0de10c35dc2cc235479b1L1-R40) [[2]](diffhunk://#diff-0f9368690552ac79a208dde6e3f09cf9f4e350e910d0de10c35dc2cc235479b1R77-R137) [[3]](diffhunk://#diff-0f9368690552ac79a208dde6e3f09cf9f4e350e910d0de10c35dc2cc235479b1L163-R149) - Changed utility action from "Clear saved token" to "Logout" and updated related assertions. [[1]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L302-R342) [[2]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14R368-L334) **UI & Workflow State Assertions:** - Added and updated assertions for new UI states and elements, such as `data-state` attributes on `.form-shell` and `.result-shell`, to verify correct workflow transitions (create, token prompt, result, etc.). [[1]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L78-R105) [[2]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14R189-R193) [[3]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14R240-R310) - Updated link and utility button expectations to match the new UI, including the presence of the "Bookmarklet" and "Logout" options. [[1]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L78-R105) [[2]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14R368-L334) **Error & Warning Handling:** - Updated test mocks and assertions to handle structured error objects for conversion errors and preview failures, ensuring that error messages and workflow states are surfaced correctly in the UI. [[1]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14R240-R310) [[2]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L281-L287) [[3]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L364-R407) [[4]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L392-R441) **Test Coverage & Miscellaneous:** - Removed tests related to deprecated API metadata and strategy fallback logic, and added/updated tests for deep linking, stale results, and permissive URL input. [[1]](diffhunk://#diff-0f9368690552ac79a208dde6e3f09cf9f4e350e910d0de10c35dc2cc235479b1R77-R137) [[2]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L119-R148) [[3]](diffhunk://#diff-1f633b86af4bf00bbb33177b2e1a51a960711d1bb9901ce853b34e53d6bd7d14L159-R179) These changes ensure the test suite accurately reflects the streamlined user experience and improved error handling in the updated frontend.
1 parent dfca027 commit 2d1b71a

23 files changed

Lines changed: 1933 additions & 2351 deletions

frontend/e2e/smoke.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ test.describe('frontend smoke', () => {
5151

5252
await expect(page.getByLabel('Page URL')).toBeVisible();
5353
await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible();
54-
await expect(page.getByRole('button', { name: 'More' })).toBeVisible();
54+
await expect(page.getByLabel('Utilities')).toBeVisible();
55+
await expect(page.getByRole('link', { name: 'Bookmarklet' })).toBeVisible();
5556

5657
await page.getByLabel('Page URL').fill('https://example.com/articles');
5758
await page.getByRole('button', { name: 'Generate feed URL' }).click();
@@ -63,6 +64,7 @@ test.describe('frontend smoke', () => {
6364

6465
await page.getByRole('button', { name: 'Back' }).click();
6566
await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible();
66-
await expect(page.getByRole('button', { name: 'More' })).toBeVisible();
67+
await expect(page.getByLabel('Utilities')).toBeVisible();
68+
await expect(page.getByRole('link', { name: 'Bookmarklet' })).toBeVisible();
6769
});
6870
});
Lines changed: 68 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,43 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { render, screen, fireEvent, waitFor } from '@testing-library/preact';
33
import { http, HttpResponse } from 'msw';
4-
import { server, buildFeedResponse } from './mocks/server';
4+
import { server, buildFeedResponse, buildStructuredErrorResponse } from './mocks/server';
55
import { App } from '../components/App';
66

77
describe('App contract', () => {
88
const token = 'contract-token';
99

10-
const authenticate = () => {
11-
globalThis.localStorage.setItem('html2rss_access_token', token);
12-
};
10+
beforeEach(() => {
11+
globalThis.history.replaceState({}, '', 'http://localhost:3000/#/create');
12+
globalThis.localStorage.clear();
13+
globalThis.sessionStorage.clear();
14+
globalThis.sessionStorage.setItem('html2rss_access_token', token);
15+
});
16+
17+
it('shows feed result when the API returns structured create payload and preview feed', async () => {
18+
const nativeFetch = globalThis.fetch;
19+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
20+
if (String(input).endsWith('/api/v1/feeds/generated-token.json')) {
21+
expect((init?.headers as Record<string, string> | undefined)?.Accept).toBe('application/feed+json');
22+
return Promise.resolve(
23+
new Response(
24+
JSON.stringify({
25+
items: [
26+
{
27+
title: 'Contract Item',
28+
content_text: 'Contract preview excerpt.',
29+
url: 'https://example.com/contract-item',
30+
date_published: '2024-01-01T00:00:00Z',
31+
},
32+
],
33+
}),
34+
{ status: 200, headers: { 'Content-Type': 'application/feed+json' } }
35+
)
36+
);
37+
}
1338

14-
it('shows feed result when API responds with success', async () => {
15-
authenticate();
39+
return nativeFetch(input, init);
40+
});
1641

1742
server.use(
1843
http.post('/api/v1/feeds', async ({ request }) => {
@@ -27,10 +52,11 @@ describe('App contract', () => {
2752
feed_token: 'generated-token',
2853
public_url: '/api/v1/feeds/generated-token',
2954
json_public_url: '/api/v1/feeds/generated-token.json',
30-
})
55+
}),
56+
{ status: 201 }
3157
);
3258
}),
33-
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
59+
http.get('http://localhost:3000/api/v1/feeds/generated-token.json', ({ request }) => {
3460
expect(request.headers.get('accept')).toBe('application/feed+json');
3561

3662
return HttpResponse.json(
@@ -48,108 +74,67 @@ describe('App contract', () => {
4874
headers: { 'content-type': 'application/feed+json' },
4975
}
5076
);
77+
}),
78+
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
79+
expect(request.headers.get('accept')).toBe('application/feed+json');
80+
81+
return HttpResponse.json({
82+
items: [
83+
{
84+
title: 'Contract Item',
85+
content_text: 'Contract preview excerpt.',
86+
url: 'https://example.com/contract-item',
87+
date_published: '2024-01-01T00:00:00Z',
88+
},
89+
],
90+
});
5191
})
5292
);
5393

5494
render(<App />);
5595

56-
await screen.findByLabelText('Page URL');
5796
await waitFor(() => {
58-
expect(screen.getByRole('combobox')).toHaveValue('faraday');
97+
expect(screen.getByLabelText('Page URL')).toBeInTheDocument();
5998
});
99+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
60100

61101
const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement;
62102
fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } });
63-
64103
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
65104

66105
await waitFor(() => {
67106
expect(screen.getByText('Feed ready')).toBeInTheDocument();
68107
expect(screen.getByText('Example Feed')).toBeInTheDocument();
108+
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'result');
69109
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
70110
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
71-
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
72-
expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute(
73-
'href',
74-
'http://localhost:3000/api/v1/feeds/generated-token.json'
75-
);
76111
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
77-
expect(screen.getByText('Preview')).toBeInTheDocument();
78112
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
79-
expect(screen.getByText('Contract Item')).toBeInTheDocument();
80113
});
114+
fetchSpy.mockRestore();
81115
});
82116

83-
it('loads instance metadata from /api/v1 without trailing slash', async () => {
84-
let slashlessMetadataRequests = 0;
85-
let trailingSlashMetadataRequests = 0;
86-
87-
server.use(
88-
http.get('/api/v1', () => {
89-
slashlessMetadataRequests += 1;
90-
91-
return HttpResponse.json({
92-
success: true,
93-
data: {
94-
api: {
95-
name: 'html2rss-web API',
96-
description: 'RESTful API for converting websites to RSS feeds',
97-
openapi_url: 'http://example.test/openapi.yaml',
98-
},
99-
instance: {
100-
feed_creation: {
101-
enabled: true,
102-
access_token_required: true,
103-
},
104-
featured_feeds: [],
105-
},
106-
},
107-
});
108-
}),
109-
http.get('/api/v1/', () => {
110-
trailingSlashMetadataRequests += 1;
111-
112-
return HttpResponse.text('', { status: 404 });
113-
})
114-
);
115-
116-
render(<App />);
117-
118-
await screen.findByLabelText('Page URL');
119-
120-
expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeInTheDocument();
121-
expect(screen.queryByText('Instance metadata unavailable')).not.toBeInTheDocument();
122-
expect(slashlessMetadataRequests).toBeGreaterThanOrEqual(1);
123-
expect(trailingSlashMetadataRequests).toBe(0);
124-
});
125-
126-
it('shows the metadata unavailable notice when /api/v1 responds with non-JSON content', async () => {
127-
server.use(
128-
http.get('/api/v1', () => HttpResponse.text('not-json', { status: 502 })),
129-
http.get('/api/v1/', () => HttpResponse.text('', { status: 404 }))
130-
);
131-
132-
render(<App />);
133-
134-
await screen.findByText('Instance metadata unavailable');
135-
136-
expect(screen.getByText('Invalid response format from API metadata')).toBeInTheDocument();
137-
});
138-
139-
it('reopens token recovery when a saved token is rejected by /api/v1/feeds', async () => {
140-
authenticate();
141-
117+
it('reopens token recovery when a saved token is rejected by structured auth metadata', async () => {
142118
server.use(
143119
http.post('/api/v1/feeds', async () =>
144-
HttpResponse.json({ success: false, error: { message: 'Unauthorized' } }, { status: 401 })
120+
HttpResponse.json(
121+
buildStructuredErrorResponse({
122+
code: 'UNAUTHORIZED',
123+
message: 'Authentication required',
124+
kind: 'auth',
125+
retryable: false,
126+
next_action: 'enter_token',
127+
retry_action: 'none',
128+
}),
129+
{ status: 401 }
130+
)
145131
)
146132
);
147133

148134
render(<App />);
149135

150-
await screen.findByLabelText('Page URL');
151136
await waitFor(() => {
152-
expect(screen.getByRole('combobox')).toHaveValue('faraday');
137+
expect(screen.getByLabelText('Page URL')).toBeInTheDocument();
153138
});
154139

155140
fireEvent.input(screen.getByLabelText('Page URL'), {
@@ -160,7 +145,7 @@ describe('App contract', () => {
160145
await screen.findByText('Access token was rejected. Paste a valid token to continue.');
161146

162147
expect(screen.getByText('Enter access token')).toBeInTheDocument();
163-
expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument();
164-
expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull();
148+
expect(screen.queryByText("Couldn't create feed yet")).not.toBeInTheDocument();
149+
expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull();
165150
});
166151
});

0 commit comments

Comments
 (0)