From 45745c9bcaba2df11048c19f81478cebb55645d9 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 21 Mar 2026 14:38:06 +0100 Subject: [PATCH 1/2] feat: unify web and feed result surfaces --- app/web/api/v1/strategies.rb | 2 +- frontend/e2e/smoke.spec.ts | 53 +-- frontend/index.html | 1 + frontend/src/__tests__/App.contract.test.tsx | 6 +- frontend/src/__tests__/ResultDisplay.test.tsx | 29 +- .../useFeedConversion.contract.test.ts | 8 +- .../src/__tests__/useFeedConversion.test.ts | 16 +- frontend/src/components/DominantField.tsx | 8 +- frontend/src/components/ResultDisplay.tsx | 128 +++++- frontend/src/styles/main.css | 391 +++++++---------- frontend/vite.config.ts | 1 + public/rss.xsl | 408 ++++++++++++++++-- public/shared-ui.css | 220 ++++++++++ 13 files changed, 893 insertions(+), 378 deletions(-) create mode 100644 public/shared-ui.css diff --git a/app/web/api/v1/strategies.rb b/app/web/api/v1/strategies.rb index 992211b7..d34f409d 100644 --- a/app/web/api/v1/strategies.rb +++ b/app/web/api/v1/strategies.rb @@ -33,7 +33,7 @@ def index(_request) def display_name_for(name) case name.to_s - when 'faraday' then 'Standard rendering' + when 'faraday' then 'Default' when 'browserless' then 'JavaScript pages (recommended)' else name.to_s.split('_').map(&:capitalize).join(' ') end diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 7111c2e4..73e06e8d 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -2,58 +2,13 @@ import { expect, test } from '@playwright/test'; test.describe('frontend smoke', () => { test('loads create flow and inline access-token gate', async ({ page }) => { - await page.route(/\/api\/v1$/, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - data: { - api: { - name: 'html2rss-web API', - description: 'RESTful API for converting websites to RSS feeds', - openapi_url: 'http://example.test/openapi.yaml', - }, - instance: { - feed_creation: { - enabled: true, - access_token_required: true, - }, - featured_feeds: [], - }, - }, - }), - }); - }); - - await page.route(/\/api\/v1\/strategies$/, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - data: { - strategies: [ - { id: 'faraday', name: 'faraday', display_name: 'Default' }, - { - id: 'browserless', - name: 'browserless', - display_name: 'JavaScript pages (recommended)', - }, - ], - }, - meta: { total: 2 }, - }), - }); - }); - await page.goto('/'); - await expect(page.getByLabel('Page URL')).toBeVisible(); + await expect(page.getByLabel('PAGE URL')).toBeVisible(); await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'More' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'MORE' })).toBeVisible(); - await page.getByLabel('Page URL').fill('https://example.com/articles'); + 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(); @@ -63,6 +18,6 @@ test.describe('frontend smoke', () => { await page.getByRole('button', { name: 'Back' }).click(); await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'More' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'MORE' })).toBeVisible(); }); }); diff --git a/frontend/index.html b/frontend/index.html index 1324d6e2..e2538ef7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@ name="description" content="html2rss converts fixed demo pages or operator-submitted URLs into feed endpoints." /> + html2rss diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index 1ba9e86a..87d906a6 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -64,16 +64,18 @@ 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('Example Feed')).toBeInTheDocument(); expect(screen.getByLabelText('Feed URL')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute( 'href', 'http://localhost:3000/api/v1/feeds/generated-token.json' ); expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); - expect(screen.getByText('Feed preview')).toBeInTheDocument(); + expect(screen.getByText('Preview')).toBeInTheDocument(); + expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); expect(screen.getByText('Contract Item')).toBeInTheDocument(); }); }); diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index c9ad54a5..b69fd86c 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -9,7 +9,7 @@ describe('ResultDisplay', () => { id: 'test-id', name: 'Test Feed', url: 'https://example.com', - strategy: 'ssrf_filter', + strategy: 'faraday', feed_token: 'test-feed-token', public_url: 'https://example.com/feed.xml', json_public_url: 'https://example.com/feed.json', @@ -21,28 +21,44 @@ describe('ResultDisplay', () => { ok: true, json: async () => ({ items: [ - { title: 'Item One' }, - { content_text: '56 points by canpan 1 hour ago | hide | 18 comments' }, - { content_text: '2. Item Two ( example.com )' }, + { + title: 'Item One', + content_text: '

First preview item with markup.

', + url: 'https://example.com/item-one', + date_published: '2024-01-01T00:00:00Z', + }, + { + content_text: '56 points by canpan 1 hour ago | hide | 18 comments', + date_published: '2024-01-02T00:00:00Z', + }, + { + content_text: '2. Item Two ( example.com )', + url: 'https://example.com/item-two', + date_published: '2024-01-03T00:00:00Z', + }, ], }), } as Response); }); - it('renders the simplified result actions and preview', async () => { + it('renders the success state actions and richer preview cards', async () => { render(); + expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); expect(screen.getByText('Test Feed')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute( 'href', 'https://example.com/feed.json' ); 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.getByText('Latest items from this feed')).toBeInTheDocument(); }); expect(window.fetch).toHaveBeenCalledWith('https://example.com/feed.xml', { headers: { Accept: 'application/feed+json' }, @@ -59,6 +75,7 @@ describe('ResultDisplay', () => { await waitFor(() => { expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); }); }); diff --git a/frontend/src/__tests__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts index 3c46604d..a7f51a17 100644 --- a/frontend/src/__tests__/useFeedConversion.contract.test.ts +++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts @@ -13,7 +13,7 @@ describe('useFeedConversion contract', () => { const body = (await request.json()) as { url: string; strategy: string }; receivedAuthorization = request.headers.get('authorization'); - expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'ssrf_filter' }); + expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'faraday' }); return HttpResponse.json( buildFeedResponse({ @@ -30,7 +30,7 @@ describe('useFeedConversion contract', () => { const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'ssrf_filter', 'test-token-123'); + await result.current.convertFeed('https://example.com/articles', 'faraday', 'test-token-123'); }); expect(receivedAuthorization).toBe('Bearer test-token-123'); @@ -54,7 +54,7 @@ describe('useFeedConversion contract', () => { await act(async () => { await expect( - result.current.convertFeed('https://example.com/articles', 'ssrf_filter', 'token') + result.current.convertFeed('https://example.com/articles', 'faraday', 'token') ).rejects.toThrow('URL parameter is required'); }); @@ -76,7 +76,7 @@ describe('useFeedConversion contract', () => { await act(async () => { await expect( - result.current.convertFeed('https://example.com/articles', 'ssrf_filter', 'token') + result.current.convertFeed('https://example.com/articles', 'faraday', 'token') ).rejects.toThrow('Invalid response format from feed creation API'); }); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index c2583985..9fe72176 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -27,7 +27,7 @@ describe('useFeedConversion', () => { id: 'test-id', name: 'Test Feed', url: 'https://example.com', - strategy: 'ssrf_filter', + strategy: 'faraday', feed_token: 'test-token', public_url: 'https://example.com/feed.xml', json_public_url: 'https://example.com/feed.json', @@ -51,7 +51,7 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await result.current.convertFeed('https://example.com', 'ssrf_filter', 'testtoken'); + await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); }); expect(result.current.isConverting).toBe(false); @@ -77,9 +77,9 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await expect( - result.current.convertFeed('https://example.com', 'ssrf_filter', 'testtoken') - ).rejects.toThrow('Bad Request'); + await expect(result.current.convertFeed('https://example.com', 'faraday', 'testtoken')).rejects.toThrow( + 'Bad Request' + ); }); expect(result.current.isConverting).toBe(false); @@ -93,9 +93,9 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await expect( - result.current.convertFeed('https://example.com', 'ssrf_filter', 'testtoken') - ).rejects.toThrow('Network error'); + await expect(result.current.convertFeed('https://example.com', 'faraday', 'testtoken')).rejects.toThrow( + 'Network error' + ); }); expect(result.current.isConverting).toBe(false); diff --git a/frontend/src/components/DominantField.tsx b/frontend/src/components/DominantField.tsx index 75c902f3..73b1a58d 100644 --- a/frontend/src/components/DominantField.tsx +++ b/frontend/src/components/DominantField.tsx @@ -1,6 +1,7 @@ import type { JSX, Ref } from 'preact'; interface DominantFieldProps { + className?: string; id: string; label: string; value: string; @@ -19,6 +20,7 @@ interface DominantFieldProps { } export function DominantField({ + className, id, label, value, @@ -36,14 +38,14 @@ export function DominantField({ error, }: DominantFieldProps) { return ( -
-