Skip to content

Commit b11665e

Browse files
authored
feat(frontend): polish result experience and validation tooling (#964)
## Summary - polish result and utility layout for the routed frontend flow - expand Playwright smoke coverage for token and result states - add Dev Container Chromium support and make ci-ready parity guidance - update frontend tooling lockfile for Vite 7 ## Validation - Dev Container locus checked before validation/commit/push - make ready - make ci-ready - browser smoke at http://127.0.0.1:4001/#/create: create, token-required, stale-result fallback, utilities, console check
1 parent 2d1b71a commit b11665e

9 files changed

Lines changed: 386 additions & 188 deletions

File tree

.devcontainer/Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ SHELL ["/bin/sh", "-o", "pipefail", "-c"]
55
RUN apk add --no-cache \
66
bash \
77
build-base \
8+
chromium \
89
curl \
910
git \
11+
harfbuzz \
1012
libxml2-dev \
1113
libxslt-dev \
1214
nodejs \
15+
nss \
1316
npm \
1417
openssl-dev \
1518
python3 \
19+
ttf-freefont \
1620
yaml-dev \
1721
tzdata
1822

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This document defines execution constraints for AI agents. For general contribut
1515
## Agent-Specific Verification Rules
1616

1717
- Always run Dev Container smoke + `make ready` for changes.
18+
- For frontend changes or API contract/spec changes, run `make ci-ready` to mirror CI parity checks.
1819
- For frontend changes, also verify in `chrome-devtools` MCP at `http://127.0.0.1:4001/` while the Dev Container is running.
1920
- Capture a quick state check for all affected UI states (e.g., guest/member/result) to enforce state parity and avoid duplicate actions.
2021

Makefile

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# frozen_string_literal: true
22

3-
.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e
3+
.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready ci-ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e
4+
5+
RUBOCOP_FLAGS ?= --cache false
46

57
# Default target
68
help: ## Show this help message
@@ -60,7 +62,7 @@ lint: lint-ruby lint-js ## Run all linters (Ruby + Frontend) - errors when issue
6062

6163
lint-ruby: ## Run Ruby linter (RuboCop) - errors when issues found
6264
@echo "Running RuboCop linting..."
63-
bundle exec rubocop
65+
bundle exec rubocop $(RUBOCOP_FLAGS)
6466
@echo "Running Zeitwerk eager-load check..."
6567
bundle exec rake zeitwerk:verify
6668
@echo "Running YARD public-method docs check..."
@@ -105,6 +107,13 @@ ready: ## Pre-commit gate (quick checks + RSpec)
105107
bundle exec rspec
106108
@echo "Pre-commit checks complete!"
107109

110+
ci-ready: ## CI parity gate (ready + OpenAPI verify + frontend e2e smoke)
111+
@echo "Running CI parity checks..."
112+
$(MAKE) ready
113+
$(MAKE) openapi-verify
114+
$(MAKE) test-frontend-e2e
115+
@echo "CI parity checks complete!"
116+
108117
yard-verify-public-docs: ## Verify essential YARD docs for all public methods in app/
109118
bundle exec rake yard:verify_public_docs
110119

frontend/e2e/smoke.spec.ts

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,45 +26,112 @@ test.describe('frontend smoke', () => {
2626
});
2727
});
2828

29-
await page.route(/\/api\/v1\/strategies$/, async (route) => {
29+
await page.goto('/');
30+
31+
await expect(page.getByLabel('Page URL')).toBeVisible();
32+
await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible();
33+
34+
await page.getByLabel('Page URL').fill('https://example.com/articles');
35+
await page.getByRole('button', { name: 'Generate feed URL' }).click();
36+
37+
await expect(page.getByRole('heading', { name: 'Enter access token' })).toBeVisible();
38+
await expect(page.getByRole('textbox', { name: 'Access token' })).toBeVisible();
39+
await expect(page.getByRole('button', { name: 'Save and continue' })).toBeVisible();
40+
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible();
41+
42+
await page.getByRole('button', { name: 'Back' }).click();
43+
await expect(page).toHaveURL(/#\/create(?:\?.*)?$/);
44+
await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible();
45+
await expect(page.locator('.form-shell')).toHaveAttribute('data-state', 'create');
46+
await expect(page.getByLabel('Utilities')).toBeVisible();
47+
});
48+
49+
test('shows result after successful feed creation without snapshot recovery', async ({ page }) => {
50+
await page.route(/\/api\/v1$/, async (route) => {
3051
await route.fulfill({
3152
status: 200,
3253
contentType: 'application/json',
3354
body: JSON.stringify({
3455
success: true,
3556
data: {
36-
strategies: [
37-
{ id: 'faraday', name: 'faraday', display_name: 'Default' },
38-
{
39-
id: 'browserless',
40-
name: 'browserless',
41-
display_name: 'JavaScript pages (recommended)',
57+
api: {
58+
name: 'html2rss-web API',
59+
description: 'RESTful API for converting websites to RSS feeds',
60+
openapi_url: 'http://example.test/openapi.yaml',
61+
},
62+
instance: {
63+
feed_creation: {
64+
enabled: true,
65+
access_token_required: true,
4266
},
43-
],
67+
featured_feeds: [],
68+
},
4469
},
45-
meta: { total: 2 },
4670
}),
4771
});
4872
});
4973

50-
await page.goto('/');
74+
await page.route(/\/api\/v1\/feeds$/, async (route) => {
75+
await route.fulfill({
76+
status: 201,
77+
contentType: 'application/json',
78+
body: JSON.stringify({
79+
success: true,
80+
data: {
81+
feed: {
82+
id: 'feed-123',
83+
name: 'Example Feed',
84+
url: 'https://example.com/articles',
85+
feed_token: 'generated-token',
86+
public_url: '/api/v1/feeds/generated-token',
87+
json_public_url: '/api/v1/feeds/generated-token.json',
88+
created_at: '2026-04-05T08:59:00.000Z',
89+
updated_at: '2026-04-05T09:00:00.000Z',
90+
},
91+
},
92+
}),
93+
});
94+
});
5195

52-
await expect(page.getByLabel('Page URL')).toBeVisible();
53-
await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible();
54-
await expect(page.getByLabel('Utilities')).toBeVisible();
55-
await expect(page.getByRole('link', { name: 'Bookmarklet' })).toBeVisible();
96+
await page.route(/\/api\/v1\/feeds\/generated-token\.json$/, async (route) => {
97+
await route.fulfill({
98+
status: 200,
99+
contentType: 'application/feed+json',
100+
body: JSON.stringify({
101+
items: [
102+
{
103+
title: 'Sample preview item',
104+
content_text: 'Current preview fetch includes rendered content.',
105+
date_published: '2026-04-05T09:00:00.000Z',
106+
url: 'https://example.com/articles/sample-preview-item',
107+
},
108+
],
109+
}),
110+
});
111+
});
112+
113+
await page.addInitScript(() => {
114+
sessionStorage.setItem('html2rss_access_token', 'token-123');
115+
});
56116

117+
await page.goto('/');
57118
await page.getByLabel('Page URL').fill('https://example.com/articles');
58119
await page.getByRole('button', { name: 'Generate feed URL' }).click();
59120

60-
await expect(page.getByRole('heading', { name: 'Enter access token' })).toBeVisible();
61-
await expect(page.getByRole('textbox', { name: 'Access token' })).toBeVisible();
62-
await expect(page.getByRole('button', { name: 'Save and continue' })).toBeVisible();
63-
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible();
121+
await expect(page.getByRole('heading', { name: 'Feed ready' })).toBeVisible();
122+
await expect(page.locator('.result-shell')).toHaveAttribute('data-state', 'result');
123+
await expect(page.getByText('Example Feed')).toBeVisible();
124+
await expect(page.getByRole('link', { name: 'Open feed' })).toBeVisible();
125+
await expect(page.getByRole('link', { name: 'Open JSON Feed' })).toBeVisible();
126+
await expect(page.getByRole('link', { name: 'Open in feed reader' })).toBeVisible();
127+
await expect(page.getByRole('button', { name: 'Create another feed' })).toBeVisible();
128+
await expect(page.getByText('Sample preview item')).toBeVisible();
129+
await expect(page.getByText('Current preview fetch includes rendered content.')).toBeVisible();
64130

65-
await page.getByRole('button', { name: 'Back' }).click();
66-
await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible();
67-
await expect(page.getByLabel('Utilities')).toBeVisible();
68-
await expect(page.getByRole('link', { name: 'Bookmarklet' })).toBeVisible();
131+
await page.goto('/#/result/missing-token');
132+
133+
await expect(page.getByLabel('Page URL')).toBeVisible();
134+
await expect(page.getByText('Saved result unavailable')).toHaveCount(0);
135+
await expect(page.locator('.result-recovery')).toHaveCount(0);
69136
});
70137
});

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"stylelint-config-standard": "^40.0.0",
5252
"typescript": "^5.9.3",
5353
"typescript-eslint": "^8.59.1",
54-
"vite": "^6.4.2",
54+
"vite": "^7.3.2",
5555
"vitest": "^3.2.4"
5656
}
5757
}

frontend/playwright.config.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
1+
import { existsSync } from 'node:fs';
12
import { defineConfig, devices } from '@playwright/test';
23

3-
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
4+
const chromiumExecutablePath =
5+
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || resolveChromiumExecutablePath();
6+
7+
function resolveChromiumExecutablePath(): string | undefined {
8+
const candidates = [
9+
'/usr/bin/chromium-browser',
10+
'/usr/bin/chromium',
11+
'/home/vscode/.cache/ms-playwright/chromium-1217/chrome-linux/chrome',
12+
];
13+
14+
return candidates.find((candidate) => existsSync(candidate));
15+
}
416

517
export default defineConfig({
618
testDir: './e2e',

0 commit comments

Comments
 (0)