diff --git a/.github/workflows/react-app-ci.yml b/.github/workflows/react-app-ci.yml
index 9dccb1198..1d7618319 100644
--- a/.github/workflows/react-app-ci.yml
+++ b/.github/workflows/react-app-ci.yml
@@ -9,7 +9,7 @@ permissions:
concurrency:
group: react-app-ci-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
@@ -31,3 +31,80 @@ jobs:
- run: npm run typecheck
- run: npm test
- run: npm run build
+
+ e2e:
+ name: e2e (Playwright, prod build + vite preview)
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ defaults:
+ run:
+ working-directory: react-app
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: react-app/package-lock.json
+ - run: npm ci
+ - name: Install Playwright Chromium
+ run: npx playwright install --with-deps chromium
+ # playwright.config.ts's webServer runs `npm run build && npm run preview`.
+ - name: Run e2e
+ run: npm run e2e
+ - name: Upload Playwright report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: |
+ react-app/playwright-report/
+ react-app/test-results/
+ retention-days: 14
+ if-no-files-found: ignore
+
+ deploy:
+ name: deploy to Firebase Hosting (master, only when credentials are set)
+ needs: [build, e2e]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+ runs-on: ubuntu-latest
+ # Credentials are checked inside the job so production-environment secrets are visible to the check.
+ environment: production
+ env:
+ FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
+ FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
+ # Angular's .firebaserc default project; override with the FIREBASE_PROJECT_ID repository variable.
+ FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID || 'angular2-hn' }}
+ defaults:
+ run:
+ working-directory: react-app
+ steps:
+ - name: Check Firebase credentials
+ id: credentials
+ working-directory: .
+ run: |
+ if [ -n "$FIREBASE_SERVICE_ACCOUNT" ] || [ -n "$FIREBASE_TOKEN" ]; then
+ echo "available=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "available=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::FIREBASE_SERVICE_ACCOUNT / FIREBASE_TOKEN secret not set; skipping Firebase deploy."
+ fi
+ - if: steps.credentials.outputs.available == 'true'
+ uses: actions/checkout@v4
+ - if: steps.credentials.outputs.available == 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: react-app/package-lock.json
+ - if: steps.credentials.outputs.available == 'true'
+ run: npm ci && npm run build
+ - name: Deploy to Firebase Hosting
+ if: steps.credentials.outputs.available == 'true'
+ run: |
+ if [ -n "$FIREBASE_SERVICE_ACCOUNT" ]; then
+ export GOOGLE_APPLICATION_CREDENTIALS="$RUNNER_TEMP/firebase-sa.json"
+ printf '%s' "$FIREBASE_SERVICE_ACCOUNT" > "$GOOGLE_APPLICATION_CREDENTIALS"
+ unset FIREBASE_TOKEN
+ fi
+ npx --yes firebase-tools@15 deploy --only hosting --project "$FIREBASE_PROJECT_ID" --non-interactive
diff --git a/react-app/e2e/app.spec.ts b/react-app/e2e/app.spec.ts
new file mode 100644
index 000000000..39e320ce1
--- /dev/null
+++ b/react-app/e2e/app.spec.ts
@@ -0,0 +1,32 @@
+import { expect, test } from '@playwright/test';
+
+import { collectConsoleErrors, mockHnApi } from './fixtures';
+
+// Port of the Angular protractor spec (e2e/src/app.e2e-spec.ts): the app loads and the browser
+// logs no SEVERE errors. The Angular "Welcome to angular-hnpwa!" title check was stale CLI scaffolding.
+test.describe('app', () => {
+ test.beforeEach(async ({ page }) => {
+ await mockHnApi(page);
+ });
+
+ test('loads the app shell without console errors', async ({ page }) => {
+ const errors = collectConsoleErrors(page);
+ await page.goto('/');
+
+ await expect(page).toHaveTitle('Angular 2 HN');
+ await expect(page.locator('#root .wrapper')).toBeVisible();
+ await expect(page.locator('#header')).toBeVisible();
+ await expect(page.locator('#footer')).toBeAttached();
+ // The inline index.html app-loader hides once React has rendered into #root.
+ await expect(page.locator('.app-loader')).toHaveCSS('opacity', '0');
+
+ await page.waitForLoadState('networkidle');
+ expect(errors).toEqual([]);
+ });
+
+ test('links the manifest and theme colour from index.html', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest');
+ await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#b92b27');
+ });
+});
diff --git a/react-app/e2e/fixtures.ts b/react-app/e2e/fixtures.ts
new file mode 100644
index 000000000..e9cd1a6c9
--- /dev/null
+++ b/react-app/e2e/fixtures.ts
@@ -0,0 +1,118 @@
+import type { BrowserContext, Page } from '@playwright/test';
+
+/**
+ * Shared HN API fixtures and helpers for the e2e specs.
+ *
+ * Selectors are Angular class names kept by the React port (MIGRATION.md), never placeholder test ids:
+ * - shell (this workstream): `#header`, `a.home-link`, `.header-nav a`, `img.settings`, `#popup1`, `.close`, `#footer`
+ * - feature pages (other workstreams, specs marked `test.fixme` until they land): `.main-content`, `.post`,
+ * `.nav .more`, `.nav .prev`, `.item`, `.comment-list`, `.collapse`, `.profile`, `.main-details .name`
+ */
+export const HN_API = 'https://node-hnapi.herokuapp.com';
+export const HNPWA_API = 'https://api.hnpwa.com/v0';
+
+export const FEED_TYPES = ['news', 'newest', 'show', 'ask', 'jobs'] as const;
+
+export const story = {
+ id: 8863,
+ title: 'My YC app: Dropbox - Throw away your USB drive',
+ points: 104,
+ user: 'dhouston',
+ time: 1175714200,
+ time_ago: '15 years ago',
+ comments_count: 1,
+ type: 'link',
+ url: 'http://www.getdropbox.com/u/2/screencast.html',
+ domain: 'getdropbox.com',
+};
+
+export const item = {
+ ...story,
+ content: '',
+ comments: [
+ {
+ id: 9224,
+ level: 0,
+ user: 'brett',
+ time: 1175727286,
+ time_ago: '15 years ago',
+ content: '
Fixture comment body
',
+ comments: [
+ {
+ id: 9225,
+ level: 1,
+ user: 'reply-user',
+ time: 1175727300,
+ time_ago: '15 years ago',
+ content: 'Fixture reply body
',
+ comments: [],
+ },
+ ],
+ },
+ ],
+};
+
+export const user = {
+ id: 'pg',
+ created_time: 1160418092,
+ created: '17 years ago',
+ karma: 155111,
+ about: 'Fixture about text
',
+};
+
+/** Whatever the router outlet rendered between `#header` and `#footer` (placeholder or real page). */
+export function outlet(page: Page) {
+ return page.locator('#root .wrapper > header + :not(#footer)');
+}
+
+export const urls = {
+ feed: (feed: string, page = 1) => `${HN_API}/${feed}?page=${page}`,
+ item: (id: number) => `${HN_API}/item/${id}`,
+ user: (id: string) => `${HNPWA_API}/user/${id}.json`,
+};
+
+const CORS = { 'access-control-allow-origin': '*' };
+
+export interface MockOptions {
+ /** Delay every API response (used to keep the first request in flight while the SW installs). */
+ latencyMs?: number;
+}
+
+/** Mocks node-hnapi (feeds/items) and HNPWA (users) on a page or the whole context (SW requests too). */
+export async function mockHnApi(target: Page | BrowserContext, { latencyMs = 0 }: MockOptions = {}) {
+ const delay = () => (latencyMs ? new Promise((resolve) => setTimeout(resolve, latencyMs)) : undefined);
+ await target.route(`${HN_API}/**`, async (route) => {
+ await delay();
+ const { pathname } = new URL(route.request().url());
+ const match = /^\/item\/(\d+)$/.exec(pathname);
+ if (match) {
+ const body = Number(match[1]) === item.id ? item : { error: `Item ${match[1]} not found` };
+ return route.fulfill({ json: body, headers: CORS });
+ }
+ return route.fulfill({
+ json: Array.from({ length: 30 }, (_, i) => ({ ...story, id: story.id + i })),
+ headers: CORS,
+ });
+ });
+ await target.route(`${HNPWA_API}/**`, async (route) => {
+ await delay();
+ const known = new URL(route.request().url()).pathname === `/v0/user/${user.id}.json`;
+ return route.fulfill({ json: known ? user : null, headers: CORS });
+ });
+}
+
+/**
+ * Port of the protractor afterEach (no SEVERE browser log entries). With `serviceWorkers: 'block'`
+ * registerSW logs "Service worker registration failed" on every page; that one is expected.
+ */
+export function collectConsoleErrors(page: Page): string[] {
+ const errors: string[] = [];
+ page.on('console', (message) => {
+ if (message.type() !== 'error') return;
+ const text = message.text();
+ if (text.startsWith('Service worker registration failed')) return;
+ errors.push(text);
+ });
+ page.on('pageerror', (error) => errors.push(error.message));
+ return errors;
+}
diff --git a/react-app/e2e/offline.spec.ts b/react-app/e2e/offline.spec.ts
new file mode 100644
index 000000000..3ca031975
--- /dev/null
+++ b/react-app/e2e/offline.spec.ts
@@ -0,0 +1,87 @@
+///
+import { expect, test } from '@playwright/test';
+
+import { item, mockHnApi, outlet, story, urls, user } from './fixtures';
+import { fetchJson, goOffline, isCached, waitForServiceWorkerControl } from './sw-helpers';
+
+test.use({ serviceWorkers: 'allow' });
+
+// Slow enough that the first feed request is still in flight when the new SW takes control.
+const API_LATENCY_MS = 1500;
+const FEED_URL = urls.feed('news', 1);
+
+test('app shell, deep links and visited API responses work offline', async ({ context, page }) => {
+ await mockHnApi(context, { latencyMs: API_LATENCY_MS });
+ // Stand-in for FeedList until the feature lands: request the feed as the page boots, i.e. before the SW
+ // controls it, which is exactly the first-visit case registerSW's cache warm-up handles.
+ await page.addInitScript((url) => {
+ if (location.pathname === '/news/1' && !navigator.serviceWorker.controller) void fetch(url);
+ }, FEED_URL);
+
+ await page.goto('/news/1');
+ await expect(outlet(page)).toBeAttached();
+ await waitForServiceWorkerControl(page);
+
+ await expect.poll(() => isCached(page, 'hn-api', FEED_URL), { timeout: 15_000 }).toBe(true);
+ // Requests made while controlled go through the SW's NetworkFirst routes and are cached.
+ await fetchJson(page, urls.item(item.id));
+ await fetchJson(page, urls.user(user.id));
+ expect(await isCached(page, 'hn-api', urls.item(item.id))).toBe(true);
+ expect(await isCached(page, 'hnpwa-api', urls.user(user.id))).toBe(true);
+
+ await goOffline(context);
+
+ const reload = await page.reload();
+ expect(reload?.fromServiceWorker()).toBe(true);
+ await expect(page).toHaveTitle('Angular 2 HN');
+ await expect(page.locator('#header')).toBeVisible();
+ await expect(outlet(page)).toBeAttached();
+ // App-shell images are precached.
+ await expect(page.locator('.app-loader img.logo')).toHaveJSProperty('complete', true);
+ expect(await page.evaluate(async () => (await fetch('/assets/images/cog.svg')).ok)).toBe(true);
+ // Visited API data comes from the runtime cache.
+ expect((await fetchJson(page, FEED_URL))[0]).toMatchObject({ id: story.id, title: story.title });
+
+ // Deep links never loaded as documents: navigateFallback + precached lazy chunks.
+ const itemPage = await page.goto(`/item/${item.id}`);
+ expect(itemPage?.fromServiceWorker()).toBe(true);
+ await expect(outlet(page)).toBeAttached();
+ expect(await fetchJson(page, urls.item(item.id))).toMatchObject({ id: item.id, title: item.title });
+
+ const userPage = await page.goto(`/user/${user.id}`);
+ expect(userPage?.fromServiceWorker()).toBe(true);
+ await expect(outlet(page)).toBeAttached();
+ expect(await fetchJson(page, urls.user(user.id))).toMatchObject({ id: user.id, karma: user.karma });
+ // Route params that look like file names still get the shell (only static files are denylisted).
+ const dottedUserPage = await page.goto('/user/john.json');
+ expect(dottedUserPage?.fromServiceWorker()).toBe(true);
+ await expect(outlet(page)).toBeAttached();
+
+ // Negative controls: API data never fetched online is not available, and static files are not
+ // answered with the SPA fallback.
+ await expect(fetchJson(page, urls.item(1))).rejects.toThrow();
+ await expect(fetchJson(page, urls.feed('show', 2))).rejects.toThrow();
+ await expect(page.goto('/missing-file.png')).rejects.toThrow();
+});
+
+// fixme until Workstreams 2/3 (FeedList, ItemDetails) are merged into the integration branch.
+test.fixme('visited feed and item render offline from the cache (real components)', async ({ context, page }) => {
+ await mockHnApi(context, { latencyMs: API_LATENCY_MS });
+ await page.goto('/news/1');
+ await expect(page.getByRole('link', { name: story.title }).filter({ visible: true }).first()).toBeVisible();
+ await waitForServiceWorkerControl(page);
+ await expect.poll(() => isCached(page, 'hn-api', FEED_URL), { timeout: 15_000 }).toBe(true);
+ await page.getByRole('link', { name: '1 comment' }).filter({ visible: true }).first().click();
+ await expect(page.locator('.comment-list')).toContainText('Fixture comment body');
+ await page.goBack();
+
+ await goOffline(context);
+ await page.reload();
+ await expect(page.locator('.post').first()).toBeVisible();
+ await page.goto(`/item/${item.id}`);
+ await expect(page.locator('.comment-list')).toContainText('Fixture comment body');
+
+ // Uncached route: the shell renders but the data request fails → Angular's error message.
+ await page.goto('/show/2');
+ await expect(page.getByRole('alert')).toContainText('Could not load show stories.');
+});
diff --git a/react-app/e2e/pwa.spec.ts b/react-app/e2e/pwa.spec.ts
new file mode 100644
index 000000000..6964b79c6
--- /dev/null
+++ b/react-app/e2e/pwa.spec.ts
@@ -0,0 +1,91 @@
+///
+import { expect, test } from '@playwright/test';
+
+import { mockHnApi, outlet } from './fixtures';
+import { waitForServiceWorkerControl } from './sw-helpers';
+
+test.use({ serviceWorkers: 'allow' });
+
+test.beforeEach(async ({ context }) => {
+ await mockHnApi(context);
+});
+
+test('serves the manifest ported from Angular src/manifest.json', async ({ page, request }) => {
+ const response = await request.get('/manifest.webmanifest');
+ expect(response.ok()).toBe(true);
+ const manifest = await response.json();
+ expect(manifest).toEqual({
+ name: 'Angular 2 HN',
+ short_name: 'Angular 2 HN',
+ icons: [144, 192, 256, 512].map((size) => ({
+ src: `assets/icons/android-chrome-${size}x${size}.png`,
+ sizes: `${size}x${size}`,
+ type: 'image/png',
+ })),
+ theme_color: '#b92b27',
+ background_color: '#ffffff',
+ display: 'standalone',
+ orientation: 'portrait',
+ start_url: './?utm_source=web_app_manifest',
+ });
+ for (const icon of manifest.icons) {
+ const iconResponse = await request.get(`/${icon.src}`);
+ expect(iconResponse.ok(), icon.src).toBe(true);
+ expect(iconResponse.headers()['content-type']).toContain('image/png');
+ }
+ await page.goto('/');
+ await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest');
+});
+
+test('registers an active service worker and is installable', async ({ page }) => {
+ await page.goto('/news/1');
+ await waitForServiceWorkerControl(page);
+ expect(await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL)).toMatch(/\/sw\.js$/);
+
+ // Playwright contexts are incognito, which Chrome always reports; every other check must pass.
+ const cdp = await page.context().newCDPSession(page);
+ const installabilityErrors = async () =>
+ (await cdp.send('Page.getInstallabilityErrors')).installabilityErrors
+ .map(({ errorId }) => errorId)
+ .filter((errorId) => errorId !== 'in-incognito');
+ await expect.poll(installabilityErrors, { timeout: 10_000 }).toEqual([]);
+ const { url, errors } = await cdp.send('Page.getAppManifest');
+ expect(url).toMatch(/\/manifest\.webmanifest$/);
+ expect(errors).toEqual([]);
+});
+
+test('/ngsw-worker.js is the safety worker, not the SPA fallback', async ({ request }) => {
+ const response = await request.get('/ngsw-worker.js');
+ expect(response.ok()).toBe(true);
+ expect(response.headers()['content-type']).toContain('javascript');
+ const body = await response.text();
+ expect(body).toContain("key.startsWith('ngsw:')");
+ expect(body).toContain('registration.unregister()');
+ expect(body).not.toContain(' {
+ await page.goto('/news/1');
+ await waitForServiceWorkerControl(page);
+
+ // Returning Angular visitor: ngsw caches plus the registration's script switching to /ngsw-worker.js
+ // (what the browser's update check does). The safety worker cleans up and reloads the tab.
+ const reloaded = page.waitForEvent('load');
+ await page.evaluate(async () => {
+ await (await caches.open('ngsw:/:db:control')).put('/x', new Response('x'));
+ void navigator.serviceWorker.register('/ngsw-worker.js');
+ });
+ await reloaded;
+
+ await expect
+ .poll(() => page.evaluate(async () => (await caches.keys()).filter((key) => key.startsWith('ngsw:'))))
+ .toEqual([]);
+ // The reloaded React page registers its own SW again.
+ await expect
+ .poll(() =>
+ page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.active?.scriptURL ?? '')
+ )
+ .toMatch(/\/sw\.js$/);
+ await expect(page.locator('#header')).toBeVisible();
+ await expect(outlet(page)).toBeAttached();
+});
diff --git a/react-app/e2e/routes.spec.ts b/react-app/e2e/routes.spec.ts
new file mode 100644
index 000000000..46483ef53
--- /dev/null
+++ b/react-app/e2e/routes.spec.ts
@@ -0,0 +1,120 @@
+import { expect, test } from '@playwright/test';
+
+import { collectConsoleErrors, FEED_TYPES, item, mockHnApi, outlet, story, user } from './fixtures';
+
+test.beforeEach(async ({ page }) => {
+ await mockHnApi(page);
+});
+
+test.describe('routes (smoke, works with placeholder pages)', () => {
+ test('/ redirects to /news/1', async ({ page }) => {
+ await page.goto('/');
+ await expect(page).toHaveURL(/\/news\/1$/);
+ await expect(outlet(page)).toBeAttached();
+ });
+
+ for (const path of [...FEED_TYPES.map((feed) => `/${feed}/1`), `/item/${item.id}`, `/user/${user.id}`]) {
+ test(`${path} renders a page inside the shell without console errors`, async ({ page }) => {
+ const errors = collectConsoleErrors(page);
+ await page.goto(path);
+ await expect(page).toHaveURL(new RegExp(`${path}$`));
+ await expect(page.locator('#header')).toBeVisible();
+ await expect(outlet(page)).toBeAttached();
+ await expect(page.locator('#footer')).toBeAttached();
+ expect(errors).toEqual([]);
+ });
+ }
+
+ test('unknown URLs keep the URL and render the empty shell (Angular has no wildcard route)', async ({ page }) => {
+ await page.goto('/nope');
+ await expect(page).toHaveURL(/\/nope$/);
+ await expect(page.locator('#header')).toBeVisible();
+ await expect(outlet(page)).toHaveCount(0);
+ });
+
+ test('header logo and nav links navigate and mark the active link', async ({ page }) => {
+ await page.goto('/news/3');
+ for (const [label, feed] of [
+ ['new', 'newest'],
+ ['show', 'show'],
+ ['ask', 'ask'],
+ ['jobs', 'jobs'],
+ ]) {
+ const link = page.locator('.header-nav a', { hasText: label });
+ await link.click();
+ await expect(page).toHaveURL(new RegExp(`/${feed}/1$`));
+ await expect(link).toHaveClass(/\bactive\b/);
+ await expect(page.locator('.header-nav a.active')).toHaveCount(1);
+ }
+ await page.locator('a.home-link').click();
+ await expect(page).toHaveURL(/\/news\/1$/);
+ await expect(page.locator('a.home-link')).toHaveClass(/\bactive\b/);
+ await expect(page.locator('.header-nav a.active')).toHaveCount(0);
+ });
+
+ test('footer links to the GitHub project (hidden on mobile)', async ({ page }) => {
+ await page.goto('/news/1');
+ await expect(page.locator('#footer a')).toHaveAttribute('href', 'https://github.com/hdjirdeh/angular2-hn');
+ await expect(page.locator('#footer')).toBeVisible();
+ await page.setViewportSize({ width: 375, height: 667 });
+ await expect(page.locator('#footer')).toBeHidden();
+ });
+});
+
+// fixme until Workstreams 2 (feeds) and 3 (item-details, user) are merged into the integration branch.
+test.describe('feature pages (real components)', () => {
+ test.fixme('feed lists 30 stories and paginates with More › / ‹ Prev', async ({ page }) => {
+ await page.goto('/news/1');
+ await expect(page.locator('.post')).toHaveCount(30);
+ await expect(page.getByRole('link', { name: story.title }).filter({ visible: true }).first()).toBeVisible();
+ await expect(page.getByText(`(${story.domain})`).filter({ visible: true }).first()).toBeVisible();
+ await expect(page.locator('.nav .prev')).toHaveCount(0);
+ await page.locator('.nav .more').click();
+ await expect(page).toHaveURL(/\/news\/2$/);
+ await expect(page.locator('.post')).toHaveCount(30);
+ await expect(page.locator('ol')).toHaveAttribute('start', '31');
+ await page.locator('.nav .prev').click();
+ await expect(page).toHaveURL(/\/news\/1$/);
+ });
+
+ test.fixme('item page shows the story and its comment tree', async ({ page }) => {
+ await page.goto(`/item/${item.id}`);
+ await expect(page.locator('.item')).toBeVisible();
+ await expect(page.getByRole('link', { name: story.title }).filter({ visible: true }).first()).toBeVisible();
+ await expect(page.locator('.comment-list')).toContainText('Fixture comment body');
+ await expect(page.locator('.comment-list')).toContainText(item.comments[0].user);
+ });
+
+ test.fixme('[-] collapses a comment subtree and [+] expands it', async ({ page }) => {
+ await page.goto(`/item/${item.id}`);
+ const toggle = page.locator('.comment-list .collapse').first();
+ await expect(toggle).toHaveText('[-]');
+ await expect(page.getByText('Fixture reply body')).toBeVisible();
+ await toggle.click();
+ await expect(toggle).toHaveText('[+]');
+ await expect(page.getByText('Fixture comment body')).toBeHidden();
+ await expect(page.getByText('Fixture reply body')).toBeHidden();
+ await toggle.press('Enter');
+ await expect(toggle).toHaveText('[-]');
+ await expect(page.getByText('Fixture reply body')).toBeVisible();
+ });
+
+ test.fixme('unknown item shows the Angular error message', async ({ page }) => {
+ await page.goto('/item/1');
+ await expect(page.getByRole('alert')).toContainText('Could not load item comments.');
+ });
+
+ test.fixme('user page shows id, karma, created and about', async ({ page }) => {
+ await page.goto(`/user/${user.id}`);
+ await expect(page.locator('.profile')).toBeVisible();
+ await expect(page.locator('.main-details .name')).toHaveText(user.id);
+ await expect(page.locator('.profile')).toContainText(`${user.karma} ★`);
+ await expect(page.locator('.profile')).toContainText(`Created ${user.created}`);
+ await expect(page.locator('.profile')).toContainText('Fixture about text');
+ });
+
+ test.fixme('unknown user shows the Angular error message', async ({ page }) => {
+ await page.goto('/user/nobody-here');
+ await expect(page.getByRole('alert')).toContainText('Could not load user nobody-here.');
+ });
+});
diff --git a/react-app/e2e/settings.spec.ts b/react-app/e2e/settings.spec.ts
new file mode 100644
index 000000000..bb7207506
--- /dev/null
+++ b/react-app/e2e/settings.spec.ts
@@ -0,0 +1,124 @@
+///
+import { expect, test, type Page } from '@playwright/test';
+
+import { mockHnApi } from './fixtures';
+
+const THEMES = ['default', 'night', 'amoledblack'] as const;
+const themeClass = (theme: string) => new RegExp(`(^|\\s)${theme}(\\s|$)`);
+const stored = (page: Page, key: string) => page.evaluate((k) => localStorage.getItem(k), key);
+const background = (page: Page, selector: string) =>
+ page.locator(selector).evaluate((el) => getComputedStyle(el).backgroundColor);
+
+test.beforeEach(async ({ page }) => {
+ await mockHnApi(page);
+});
+
+test.describe('settings persistence (SettingsProvider)', () => {
+ for (const theme of THEMES) {
+ test(`stored theme "${theme}" is applied to after reload`, async ({ page }) => {
+ await page.goto('/news/1');
+ await page.evaluate((value) => localStorage.setItem('theme', value), theme);
+ await page.reload();
+ await expect(page.locator('html')).toHaveClass(themeClass(theme));
+ });
+ }
+
+ test('first visit derives the theme from prefers-color-scheme and persists it', async ({ browser }) => {
+ const context = await browser.newContext({ colorScheme: 'dark', serviceWorkers: 'block' });
+ const page = await context.newPage();
+ await mockHnApi(page);
+ await page.goto('/news/1');
+ await expect(page.locator('html')).toHaveClass(themeClass('night'));
+ expect(await stored(page, 'theme')).toBe('night');
+ await context.close();
+ });
+});
+
+test.describe('settings popup', () => {
+ test('switching the theme restyles the whole app and persists across reloads', async ({ page }) => {
+ await page.goto('/news/1');
+ await page.evaluate(() => localStorage.setItem('theme', 'default'));
+ await page.reload();
+ const seen = new Set();
+ for (const [label, theme] of [
+ ['Night', 'night'],
+ ['Black (AMOLED)', 'amoledblack'],
+ ['Default', 'default'],
+ ] as const) {
+ await page.locator('img.settings').click();
+ const dialog = page.getByRole('dialog', { name: 'Settings' });
+ await expect(dialog).toBeVisible();
+ await dialog.getByLabel(label).check();
+ await expect(page.locator('html')).toHaveClass(themeClass(theme));
+ expect(await stored(page, 'theme')).toBe(theme);
+ await page.locator('#popup1 .close').click();
+ await expect(dialog).toHaveCount(0);
+
+ await page.reload();
+ await expect(page.locator('html')).toHaveClass(themeClass(theme));
+ const colours = [
+ await background(page, '.body-cover'),
+ await background(page, '.wrapper'),
+ await background(page, '#header'),
+ ].join();
+ seen.add(colours);
+ }
+ // Each theme paints the page body, the wrapper and the header differently.
+ expect(seen.size).toBe(3);
+ });
+
+ test('font size, list spacing and open-in-new-tab persist across reloads', async ({ page }) => {
+ await page.goto('/news/1');
+ await page.locator('img.settings').click();
+ const dialog = page.getByRole('dialog', { name: 'Settings' });
+ await dialog.getByLabel('Font size:').fill('20');
+ await dialog.getByLabel('List spacing:').fill('7');
+ await dialog.getByRole('checkbox', { name: 'Open links in a new tab' }).check();
+ expect(await stored(page, 'titleFontSize')).toBe('20');
+ expect(await stored(page, 'listSpacing')).toBe('7');
+ expect(await stored(page, 'openLinkInNewTab')).toBe('true');
+
+ await page.reload();
+ await page.locator('img.settings').click();
+ await expect(dialog.getByLabel('Font size:')).toHaveValue('20');
+ await expect(dialog.getByLabel('List spacing:')).toHaveValue('7');
+ await expect(dialog.getByRole('checkbox', { name: 'Open links in a new tab' })).toBeChecked();
+ });
+
+ test('keyboard: cog opens, focus is trapped, Escape closes and focus returns to the cog', async ({ page }) => {
+ await page.goto('/news/1');
+ const cog = page.getByRole('button', { name: 'Settings' });
+ await cog.focus();
+ await page.keyboard.press('Enter');
+ const dialog = page.getByRole('dialog', { name: 'Settings' });
+ await expect(dialog).toHaveAttribute('aria-modal', 'true');
+ await expect(page.getByRole('button', { name: 'Close settings' })).toBeFocused();
+
+ for (let i = 0; i < 8; i++) {
+ await page.keyboard.press('Tab');
+ expect(await dialog.evaluate((el) => el.contains(document.activeElement))).toBe(true);
+ }
+ await page.keyboard.press('Shift+Tab');
+ expect(await dialog.evaluate((el) => el.contains(document.activeElement))).toBe(true);
+
+ await page.keyboard.press('Escape');
+ await expect(dialog).toHaveCount(0);
+ await expect(cog).toBeFocused();
+
+ await page.keyboard.press(' ');
+ await expect(dialog).toBeVisible();
+ await page.getByRole('button', { name: 'Close settings' }).press('Enter');
+ await expect(dialog).toHaveCount(0);
+ });
+
+ test('the overlay covers the header, so only × closes the popup (Angular parity)', async ({ page }) => {
+ await page.goto('/news/1');
+ await page.locator('img.settings').click();
+ const box = await page.locator('img.settings').boundingBox();
+ const hit = await page.evaluate(
+ ([x, y]) => document.elementFromPoint(x, y)?.id ?? '',
+ [box!.x + box!.width / 2, box!.y + box!.height / 2]
+ );
+ expect(hit).toBe('popup1');
+ });
+});
diff --git a/react-app/e2e/sw-helpers.ts b/react-app/e2e/sw-helpers.ts
new file mode 100644
index 000000000..77dba1016
--- /dev/null
+++ b/react-app/e2e/sw-helpers.ts
@@ -0,0 +1,32 @@
+///
+import type { BrowserContext, Page } from '@playwright/test';
+
+export async function waitForServiceWorkerControl(page: Page) {
+ await page.evaluate(async () => {
+ await navigator.serviceWorker.ready;
+ if (!navigator.serviceWorker.controller) {
+ await new Promise((resolve) =>
+ navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true })
+ );
+ }
+ });
+}
+
+/**
+ * `context.setOffline` (like DevTools "Offline") does not stop the SW's own fetches and route mocks keep
+ * answering, so drop the mocks and abort every request — SW fetches included — before going offline.
+ */
+export async function goOffline(context: BrowserContext) {
+ await context.unrouteAll({ behavior: 'ignoreErrors' });
+ await context.route('**/*', (route) => route.abort('internetdisconnected'));
+ await context.setOffline(true);
+}
+
+export function isCached(page: Page, cacheName: string, url: string) {
+ return page.evaluate(async ([name, u]) => !!(await (await caches.open(name)).match(u)), [cacheName, url] as const);
+}
+
+/** fetch() from the page, i.e. through the controlling service worker. */
+export function fetchJson(page: Page, url: string) {
+ return page.evaluate(async (u) => (await fetch(u)).json(), url);
+}
diff --git a/react-app/firebase.json b/react-app/firebase.json
new file mode 100644
index 000000000..4d42a64df
--- /dev/null
+++ b/react-app/firebase.json
@@ -0,0 +1,34 @@
+{
+ "hosting": {
+ "public": "dist",
+ "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
+ "rewrites": [
+ {
+ "regex": "^/(?:(?:news|newest|show|ask|jobs|item|user)/.*|[^.]*)$",
+ "destination": "/index.html"
+ }
+ ],
+ "headers": [
+ {
+ "source": "/assets/**",
+ "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
+ },
+ {
+ "source": "/assets/{icons,images}/**",
+ "headers": [{ "key": "Cache-Control", "value": "public, max-age=86400" }]
+ },
+ {
+ "source": "/workbox-*.js",
+ "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
+ },
+ {
+ "source": "/@(sw.js|ngsw-worker.js|index.html|manifest.webmanifest)",
+ "headers": [{ "key": "Cache-Control", "value": "no-cache" }]
+ },
+ {
+ "source": "/manifest.webmanifest",
+ "headers": [{ "key": "Content-Type", "value": "application/manifest+json" }]
+ }
+ ]
+ }
+}
diff --git a/react-app/playwright.config.ts b/react-app/playwright.config.ts
new file mode 100644
index 000000000..ff184600f
--- /dev/null
+++ b/react-app/playwright.config.ts
@@ -0,0 +1,47 @@
+// Lets context.route intercept requests made by the service worker (offline/PWA specs).
+process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
+
+import { existsSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { defineConfig, devices } from '@playwright/test';
+
+const PORT = Number(process.env.E2E_PORT ?? 4173);
+const DEVIN_CHROME_DIR = '/opt/.devin/chrome/chrome';
+
+/** Local runs without `npx playwright install` fall back to the preinstalled Chrome (CI installs Chromium). */
+function localChrome(): string | undefined {
+ if (process.env.PLAYWRIGHT_CHROME_PATH) return process.env.PLAYWRIGHT_CHROME_PATH;
+ if (process.env.CI || !existsSync(DEVIN_CHROME_DIR)) return undefined;
+ return readdirSync(DEVIN_CHROME_DIR)
+ .filter((dir) => dir.startsWith('linux-'))
+ .map((dir) => join(DEVIN_CHROME_DIR, dir, 'chrome-linux64', 'chrome'))
+ .find((path) => existsSync(path));
+}
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
+ use: {
+ baseURL: `http://localhost:${PORT}`,
+ trace: 'retain-on-failure',
+ // Once a SW controls the page its fetches bypass page.route mocks; SW/offline specs opt back in.
+ serviceWorkers: 'block',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'], launchOptions: { executablePath: localChrome() } },
+ },
+ ],
+ // Production build: the service worker and precache manifest only exist after `vite build`.
+ webServer: {
+ command: `npm run build && npx vite preview --port ${PORT} --strictPort`,
+ url: `http://localhost:${PORT}`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 180_000,
+ },
+});
diff --git a/react-app/public/manifest.webmanifest b/react-app/public/manifest.webmanifest
new file mode 100644
index 000000000..d8a04517b
--- /dev/null
+++ b/react-app/public/manifest.webmanifest
@@ -0,0 +1,31 @@
+{
+ "name": "Angular 2 HN",
+ "short_name": "Angular 2 HN",
+ "icons": [
+ {
+ "src": "assets/icons/android-chrome-144x144.png",
+ "sizes": "144x144",
+ "type": "image/png"
+ },
+ {
+ "src": "assets/icons/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "assets/icons/android-chrome-256x256.png",
+ "sizes": "256x256",
+ "type": "image/png"
+ },
+ {
+ "src": "assets/icons/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#b92b27",
+ "background_color": "#ffffff",
+ "display": "standalone",
+ "orientation": "portrait",
+ "start_url": "./?utm_source=web_app_manifest"
+}
diff --git a/react-app/public/ngsw-worker.js b/react-app/public/ngsw-worker.js
new file mode 100644
index 000000000..09f35f89b
--- /dev/null
+++ b/react-app/public/ngsw-worker.js
@@ -0,0 +1,17 @@
+// Safety worker for returning visitors who still have the Angular app's @angular/service-worker installed
+// at /ngsw-worker.js. The browser fetches this script on its next update check: it replaces the Angular
+// worker, deletes the `ngsw:*` caches, unregisters itself and reloads open tabs so they load the React app,
+// which then registers its own /sw.js. Served with no-cache headers (firebase.json), excluded from precache.
+self.addEventListener('install', () => self.skipWaiting());
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ (async () => {
+ const keys = await caches.keys();
+ await Promise.all(keys.filter((key) => key.startsWith('ngsw:')).map((key) => caches.delete(key)));
+ await self.registration.unregister();
+ const windows = await self.clients.matchAll({ type: 'window' });
+ await Promise.all(windows.map((client) => client.navigate(client.url)));
+ })()
+ );
+});
diff --git a/react-app/src/features/shell/AppShell.test.tsx b/react-app/src/features/shell/AppShell.test.tsx
index e59a3126b..1b3568f6b 100644
--- a/react-app/src/features/shell/AppShell.test.tsx
+++ b/react-app/src/features/shell/AppShell.test.tsx
@@ -1,17 +1,33 @@
import { screen } from '@testing-library/react';
+import { Route, Routes } from 'react-router';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../../shared/test-utils';
import { AppShell } from './AppShell';
-// Owned by the Shell child; extend/replace with real AppShell tests.
describe('AppShell', () => {
- it('renders .body-cover and .wrapper with header and footer', () => {
- const { container } = renderWithProviders();
+ it('renders .body-cover and .wrapper > header, outlet, footer (app.component.html)', () => {
+ const { container } = renderWithProviders(
+
+ }>
+ feed} />
+
+ ,
+ { route: '/news/1' }
+ );
expect(container.querySelector('.body-cover')).toBeInTheDocument();
const wrapper = container.querySelector('.wrapper');
- expect(wrapper?.firstElementChild).toHaveAttribute('data-testid', 'header');
- expect(wrapper?.lastElementChild).toHaveAttribute('data-testid', 'footer');
- expect(screen.getByTestId('header')).toBeInTheDocument();
+ expect(wrapper?.children).toHaveLength(3);
+ expect(wrapper?.children[0]).toHaveAttribute('data-testid', 'header');
+ expect(wrapper?.children[1]).toBe(screen.getByTestId('page'));
+ expect(wrapper?.children[2]).toHaveAttribute('data-testid', 'footer');
+ });
+
+ it('does not add its own theme wrapper; the theme class lives on ', () => {
+ localStorage.setItem('theme', 'amoledblack');
+ const { container } = renderWithProviders();
+ expect(document.documentElement).toHaveClass('amoledblack');
+ expect(container.querySelector('.amoledblack')).toBeNull();
+ expect(container.firstElementChild).toHaveClass('body-cover');
});
});
diff --git a/react-app/src/features/shell/Footer.scss b/react-app/src/features/shell/Footer.scss
new file mode 100644
index 000000000..51aed8fe2
--- /dev/null
+++ b/react-app/src/features/shell/Footer.scss
@@ -0,0 +1,23 @@
+// Port of core/footer/footer.component.scss (already scoped by #footer).
+@use '../../shared/scss/media' as *;
+
+#footer {
+ position: relative;
+ padding: 10px;
+ height: 60px;
+ letter-spacing: 0.7px;
+ text-align: center;
+
+ a {
+ font-weight: bold;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ @media #{$mobile-only} {
+ display: none;
+ }
+}
diff --git a/react-app/src/features/shell/Footer.test.tsx b/react-app/src/features/shell/Footer.test.tsx
index 41281f565..d06e32171 100644
--- a/react-app/src/features/shell/Footer.test.tsx
+++ b/react-app/src/features/shell/Footer.test.tsx
@@ -4,10 +4,15 @@ import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../../shared/test-utils';
import { Footer } from './Footer';
-// Placeholder test — owned by the Shell child; replace with real Footer tests.
-describe('Footer (placeholder)', () => {
- it('renders', () => {
- renderWithProviders();
- expect(screen.getByTestId('footer')).toBeInTheDocument();
+describe('Footer', () => {
+ it('renders the Angular footer text and GitHub link opening in a new tab', () => {
+ const { container } = renderWithProviders();
+ const footer = container.querySelector('#footer');
+ expect(footer).toHaveAttribute('data-testid', 'footer');
+ expect(footer).toHaveTextContent('Show this project some ❤ on GitHub');
+ const link = screen.getByRole('link', { name: 'GitHub' });
+ expect(link).toHaveAttribute('href', 'https://github.com/hdjirdeh/angular2-hn');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
});
});
diff --git a/react-app/src/features/shell/Footer.tsx b/react-app/src/features/shell/Footer.tsx
index d6623afb8..359954d30 100644
--- a/react-app/src/features/shell/Footer.tsx
+++ b/react-app/src/features/shell/Footer.tsx
@@ -1,4 +1,15 @@
-/** Placeholder — Shell child ports Angular `core/footer`. */
+import './Footer.scss';
+
+/** Port of `core/footer` (hidden on mobile). */
export function Footer() {
- return Footer placeholder
;
+ return (
+
+ );
}
diff --git a/react-app/src/features/shell/Header.scss b/react-app/src/features/shell/Header.scss
new file mode 100644
index 000000000..d69f586bc
--- /dev/null
+++ b/react-app/src/features/shell/Header.scss
@@ -0,0 +1,150 @@
+// Port of core/header/header.component.scss. Every rule is nested under #header so nothing leaks
+// (the Angular `.name { display: none }` and `h1` rules used to hide/shrink other components' text).
+@use '../../shared/scss/media' as *;
+
+#header {
+ color: #fff;
+ padding: 6px 0;
+ line-height: 18px;
+ vertical-align: middle;
+ position: relative;
+ z-index: 1;
+ width: 100%;
+
+ @media #{$mobile-only} {
+ height: 50px;
+ position: fixed;
+ top: 0;
+ }
+
+ a {
+ display: inline;
+ }
+
+ .home-link {
+ width: 50px;
+ height: 66px;
+ }
+
+ .logo-inner {
+ width: 32px;
+ position: absolute;
+ left: 17px;
+ top: 18px;
+ z-index: -1;
+ height: 32px;
+ border-radius: 50%;
+
+ @media #{$mobile-only} {
+ left: 16px;
+ top: 12px;
+ }
+ }
+
+ .logo {
+ width: 50px;
+ padding: 3px 8px 0;
+
+ @media #{$mobile-only} {
+ width: 45px;
+ padding: 0 0 0 10px;
+ }
+ }
+
+ h1 {
+ font-weight: normal;
+ display: inline-block;
+ vertical-align: middle;
+ margin: 0;
+ font-size: 16px;
+
+ a {
+ color: #fff;
+ text-decoration: none;
+ }
+ }
+
+ .name {
+ margin-right: 30px;
+ margin-bottom: 2px;
+
+ @media #{$mobile-only} {
+ display: none;
+ }
+ }
+
+ .header-text {
+ position: absolute;
+ width: inherit;
+ height: 20px;
+ left: 10px;
+ top: 27px;
+ z-index: -1;
+
+ @media #{$mobile-only} {
+ top: 22px;
+ }
+ }
+
+ .left {
+ position: absolute;
+ left: 60px;
+ font-size: 16px;
+
+ @media #{$mobile-only} {
+ width: 100%;
+ left: 0;
+ }
+ }
+
+ .header-nav {
+ display: inline-block;
+ margin-left: 20px;
+
+ @media #{$mobile-only} {
+ margin-left: 60px;
+ }
+
+ a {
+ color: hsla(0, 0%, 100%, 0.9);
+ text-decoration: none;
+ margin: 0 5px;
+ letter-spacing: 1.8px;
+
+ &:hover {
+ color: #fff;
+ }
+ }
+
+ .active {
+ color: #fff;
+ }
+ }
+
+ .info {
+ position: absolute;
+ top: 0;
+ right: 20px;
+ height: 100%;
+
+ @media #{$mobile-only} {
+ right: 10px;
+ }
+
+ img {
+ opacity: 0.8;
+ width: 25px;
+ margin-top: 21.5px;
+ display: block;
+
+ &:hover {
+ opacity: 1;
+ cursor: pointer;
+ }
+
+ @media #{$mobile-only} {
+ margin-top: 15px;
+ }
+ }
+ }
+}
diff --git a/react-app/src/features/shell/Header.test.tsx b/react-app/src/features/shell/Header.test.tsx
index a683f88e8..ec28e027d 100644
--- a/react-app/src/features/shell/Header.test.tsx
+++ b/react-app/src/features/shell/Header.test.tsx
@@ -4,10 +4,100 @@ import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../../shared/test-utils';
import { Header } from './Header';
-// Placeholder test — owned by the Shell child; replace with real Header tests.
-describe('Header (placeholder)', () => {
- it('renders', () => {
- renderWithProviders();
- expect(screen.getByTestId('header')).toBeInTheDocument();
+function renderHeader(route = '/news/1') {
+ // path '*' keeps the header mounted while navigating.
+ return renderWithProviders(, { route });
+}
+
+const cog = () => screen.getByRole('button', { name: 'Settings' });
+
+describe('Header', () => {
+ it('renders the logo link to /news/1 and the new | show | ask | jobs nav', () => {
+ const { container } = renderHeader();
+ const home = container.querySelector('a.home-link');
+ expect(home).toHaveAttribute('href', '/news/1');
+ expect(home?.querySelector('img.logo')).toHaveAttribute('src', '/assets/images/logo.svg');
+ expect(home?.querySelector('.logo-inner')).toBeInTheDocument();
+ const nav = container.querySelector('.header-nav');
+ expect(nav).toHaveTextContent(/^new \| show \| ask \| jobs$/);
+ expect(screen.getByRole('link', { name: 'new' })).toHaveAttribute('href', '/newest/1');
+ expect(screen.getByRole('link', { name: 'show' })).toHaveAttribute('href', '/show/1');
+ expect(screen.getByRole('link', { name: 'ask' })).toHaveAttribute('href', '/ask/1');
+ expect(screen.getByRole('link', { name: 'jobs' })).toHaveAttribute('href', '/jobs/1');
+ });
+
+ it.each([
+ ['/newest/1', 'new'],
+ ['/show/1', 'show'],
+ ['/ask/1', 'ask'],
+ ['/jobs/1', 'jobs'],
+ ])('marks only the active nav link on %s', (route, label) => {
+ renderHeader(route);
+ for (const name of ['new', 'show', 'ask', 'jobs']) {
+ const link = screen.getByRole('link', { name });
+ if (name === label) expect(link).toHaveClass('active');
+ else expect(link).not.toHaveClass('active');
+ }
+ });
+
+ it('marks the home link active on /news/1 only (routerLinkActive parity)', () => {
+ const { container, unmount } = renderHeader('/news/1');
+ expect(container.querySelector('a.home-link')).toHaveClass('active');
+ unmount();
+ const second = renderHeader('/news/2');
+ expect(second.container.querySelector('a.home-link')).not.toHaveClass('active');
+ });
+
+ it('scrolls to top and navigates when a nav link or the logo is clicked', async () => {
+ const { user, container } = renderHeader('/news/3');
+ await user.click(screen.getByRole('link', { name: 'show' }));
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
+ expect(screen.getByRole('link', { name: 'show' })).toHaveClass('active');
+ await user.click(container.querySelector('a.home-link') as HTMLElement);
+ expect(window.scrollTo).toHaveBeenCalledTimes(2);
+ expect(container.querySelector('a.home-link')).toHaveClass('active');
+ });
+
+ it('cog is a keyboard-operable button that toggles the Settings popup', async () => {
+ const { user } = renderHeader();
+ expect(cog()).toHaveAttribute('tabindex', '0');
+ expect(cog()).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ await user.click(cog());
+ expect(screen.getByRole('dialog', { name: 'Settings' })).toBeInTheDocument();
+ expect(cog()).toHaveAttribute('aria-expanded', 'true');
+
+ // A synthetic second click (no hit-testing in jsdom) toggles it closed like Angular's handler.
+ await user.click(cog());
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ cog().focus();
+ await user.keyboard('{Enter}');
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ await user.keyboard('{Escape}');
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ cog().focus();
+ await user.keyboard(' ');
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('moves focus into the popup and returns it to the cog on close', async () => {
+ const { user } = renderHeader();
+ await user.click(cog());
+ const close = screen.getByRole('button', { name: 'Close settings' });
+ expect(close).toHaveFocus();
+ await user.click(close);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(cog()).toHaveFocus();
+ });
+
+ it('renders the popup inside , after #header (overlay stacks above the header)', async () => {
+ const { user, container } = renderHeader();
+ await user.click(cog());
+ const header = container.querySelector('header');
+ expect(header?.children[0]).toHaveAttribute('id', 'header');
+ expect(header?.children[1]).toHaveAttribute('id', 'popup1');
});
});
diff --git a/react-app/src/features/shell/Header.tsx b/react-app/src/features/shell/Header.tsx
index e8ace82c6..281cfc052 100644
--- a/react-app/src/features/shell/Header.tsx
+++ b/react-app/src/features/shell/Header.tsx
@@ -1,4 +1,78 @@
-/** Placeholder — Shell child ports Angular `core/header` (renders `` when `showSettings`). */
+import { Fragment, useEffect, useRef, type KeyboardEvent } from 'react';
+import { NavLink } from 'react-router';
+
+import { useSettings } from '../../shared/settings';
+import { Settings } from './Settings';
+import './Header.scss';
+
+const NAV_LINKS = [
+ { to: '/newest/1', label: 'new' },
+ { to: '/show/1', label: 'show' },
+ { to: '/ask/1', label: 'ask' },
+ { to: '/jobs/1', label: 'jobs' },
+] as const;
+
+function scrollTop() {
+ window.scrollTo(0, 0);
+}
+
+/** Port of `core/header`: logo + feed nav (scroll to top on click), settings cog and the Settings popup. */
export function Header() {
- return ;
+ const { settings, toggleSettings } = useSettings();
+ const cogRef = useRef(null);
+ const wasOpen = useRef(settings.showSettings);
+
+ // Return focus to the cog when the popup closes.
+ useEffect(() => {
+ if (wasOpen.current && !settings.showSettings) cogRef.current?.focus();
+ wasOpen.current = settings.showSettings;
+ }, [settings.showSettings]);
+
+ const onCogKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ toggleSettings();
+ }
+ };
+
+ return (
+
+
+ {settings.showSettings && }
+
+ );
}
diff --git a/react-app/src/features/shell/Settings.scss b/react-app/src/features/shell/Settings.scss
new file mode 100644
index 000000000..8a63f8e4b
--- /dev/null
+++ b/react-app/src/features/shell/Settings.scss
@@ -0,0 +1,85 @@
+// Port of core/settings/settings.component.scss. Every rule is nested under #popup1 so the generic
+// `h1`/`h2`/`hr`/`.close`/`.content` selectors don't leak into other components.
+#popup1 {
+ &.overlay {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.7);
+ opacity: 1;
+ z-index: 1;
+ // Not in Angular: lets the popup scroll on short viewports instead of being clipped.
+ overflow-y: auto;
+ }
+
+ .popup {
+ margin: 70px auto;
+ padding: 30px;
+ border-radius: 5px;
+ width: 30%;
+ position: relative;
+
+ h1 {
+ margin-top: 0;
+ margin-bottom: 0px;
+ color: #fff;
+ text-align: center;
+ letter-spacing: 1px;
+ }
+
+ h2 {
+ padding-top: 10px;
+ }
+
+ hr {
+ width: 40%;
+ margin-bottom: 20px;
+ }
+
+ .close {
+ position: absolute;
+ top: 12px;
+ right: 20px;
+ font-size: 30px;
+ font-weight: bold;
+ text-decoration: none;
+ color: rgba(255, 255, 255, 0.8);
+
+ &:hover {
+ color: #fff;
+ cursor: pointer;
+ }
+ }
+
+ .content {
+ max-height: 30%;
+ color: #fff;
+ letter-spacing: 1px;
+ overflow: auto;
+ }
+
+ input[type='number'] {
+ display: block;
+ width: 80%;
+ height: 20px;
+ margin-bottom: 15px;
+ border-radius: 5px;
+ padding: 2px;
+ }
+ }
+
+ .control-section {
+ margin-bottom: 15px;
+ padding-bottom: 15px;
+ border-bottom: 1px solid white;
+ }
+
+ @media screen and (max-width: 700px) {
+ .box,
+ .popup {
+ width: 70%;
+ }
+ }
+}
diff --git a/react-app/src/features/shell/Settings.test.tsx b/react-app/src/features/shell/Settings.test.tsx
index 25ff3d4c1..c58f599d0 100644
--- a/react-app/src/features/shell/Settings.test.tsx
+++ b/react-app/src/features/shell/Settings.test.tsx
@@ -1,13 +1,137 @@
-import { screen } from '@testing-library/react';
+import { fireEvent, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
+import { useSettings } from '../../shared/settings';
import { renderWithProviders } from '../../shared/test-utils';
import { Settings } from './Settings';
-// Placeholder test — owned by the Shell child; replace with real Settings tests.
-describe('Settings (placeholder)', () => {
- it('renders', () => {
- renderWithProviders();
- expect(screen.getByTestId('settings')).toBeInTheDocument();
+/** Shows the popup the way Header does, so closing can be asserted. */
+function Harness() {
+ const { settings, toggleSettings } = useSettings();
+ return (
+ <>
+
+ {settings.showSettings && }
+ >
+ );
+}
+
+async function renderOpen() {
+ const result = renderWithProviders();
+ await result.user.click(screen.getByRole('button', { name: 'open' }));
+ return result;
+}
+
+describe('Settings', () => {
+ it('renders the Angular structure: #popup1.overlay > .popup dialog with title, × and sections', async () => {
+ const { container } = await renderOpen();
+ const overlay = container.querySelector('#popup1.overlay');
+ expect(overlay).toHaveAttribute('data-testid', 'settings');
+ const dialog = screen.getByRole('dialog', { name: 'Settings' });
+ expect(dialog).toHaveClass('popup');
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
+ expect(overlay).toContainElement(dialog);
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Settings');
+ expect(screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent)).toEqual([
+ 'Links',
+ 'Select a theme',
+ 'Change Font',
+ ]);
+ expect(container.querySelectorAll('.control-section')).toHaveLength(3);
+ expect(screen.getByRole('button', { name: 'Close settings' })).toHaveTextContent('×');
+ expect(screen.getByText(/Open links in a new tab/)).toBeInTheDocument();
+ });
+
+ it('reflects stored settings', async () => {
+ localStorage.setItem('openLinkInNewTab', 'true');
+ localStorage.setItem('theme', 'night');
+ localStorage.setItem('titleFontSize', '20');
+ localStorage.setItem('listSpacing', '5');
+ await renderOpen();
+ expect(screen.getByRole('checkbox', { name: 'Open links in a new tab' })).toBeChecked();
+ expect(screen.getByRole('radio', { name: 'Night' })).toBeChecked();
+ expect(screen.getByRole('radio', { name: 'Default' })).not.toBeChecked();
+ expect(screen.getByLabelText('Font size:')).toHaveValue(20);
+ expect(screen.getByLabelText('List spacing:')).toHaveValue(5);
+ });
+
+ it('shows Angular defaults when nothing is stored', async () => {
+ await renderOpen();
+ expect(screen.getByRole('checkbox')).not.toBeChecked();
+ expect(screen.getByRole('radio', { name: 'Default' })).toBeChecked();
+ expect(screen.getByLabelText('Font size:')).toHaveValue(16);
+ expect(screen.getByLabelText('Font size:')).toHaveAttribute('min', '1');
+ expect(screen.getByLabelText('List spacing:')).toHaveValue(0);
+ expect(screen.getByLabelText('List spacing:')).toHaveAttribute('min', '0');
+ });
+
+ it('toggles open-links-in-new-tab and persists it as JSON', async () => {
+ const { user } = await renderOpen();
+ const checkbox = screen.getByRole('checkbox', { name: 'Open links in a new tab' });
+ await user.click(checkbox);
+ expect(checkbox).toBeChecked();
+ expect(localStorage.getItem('openLinkInNewTab')).toBe('true');
+ await user.click(checkbox);
+ expect(checkbox).not.toBeChecked();
+ expect(localStorage.getItem('openLinkInNewTab')).toBe('false');
+ });
+
+ it.each([
+ ['Night', 'night'],
+ ['Black (AMOLED)', 'amoledblack'],
+ ['Default', 'default'],
+ ])('selecting %s sets theme %s on and in localStorage', async (label, value) => {
+ localStorage.setItem('theme', value === 'default' ? 'night' : 'default');
+ const { user } = await renderOpen();
+ await user.click(screen.getByRole('radio', { name: label }));
+ expect(screen.getByRole('radio', { name: label })).toBeChecked();
+ expect(document.documentElement).toHaveClass(value);
+ expect(localStorage.getItem('theme')).toBe(value);
+ });
+
+ it('saves font size and list spacing on change (typing and spinner)', async () => {
+ const { user } = await renderOpen();
+ const font = screen.getByLabelText('Font size:');
+ await user.clear(font);
+ await user.type(font, '22');
+ expect(localStorage.getItem('titleFontSize')).toBe('22');
+ expect(font).toHaveValue(22);
+
+ const spacing = screen.getByLabelText('List spacing:');
+ // A spinner/paste edit fires only `change` (no keyup) and must still save.
+ fireEvent.change(spacing, { target: { value: '2' } });
+ expect(localStorage.getItem('listSpacing')).toBe('2');
+ expect(spacing).toHaveValue(2);
+ });
+
+ it.each(['click', 'Enter', ' ', 'Escape'])('× closes the popup via %s', async (how) => {
+ const { user } = await renderOpen();
+ const close = screen.getByRole('button', { name: 'Close settings' });
+ if (how === 'click') await user.click(close);
+ else {
+ close.focus();
+ await user.keyboard(how === ' ' ? ' ' : `{${how}}`);
+ }
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('focuses × on open and traps Tab / Shift+Tab inside the dialog', async () => {
+ localStorage.setItem('theme', 'night');
+ const { user } = await renderOpen();
+ const close = screen.getByRole('button', { name: 'Close settings' });
+ expect(close).toHaveFocus();
+
+ await user.tab();
+ expect(screen.getByRole('checkbox')).toHaveFocus();
+ await user.tab();
+ expect(screen.getByRole('radio', { name: 'Night' })).toHaveFocus();
+ await user.tab();
+ expect(screen.getByLabelText('Font size:')).toHaveFocus();
+ await user.tab();
+ expect(screen.getByLabelText('List spacing:')).toHaveFocus();
+ await user.tab();
+ expect(close).toHaveFocus();
+ await user.tab({ shift: true });
+ expect(screen.getByLabelText('List spacing:')).toHaveFocus();
});
});
diff --git a/react-app/src/features/shell/Settings.tsx b/react-app/src/features/shell/Settings.tsx
index 9356d8b2e..f6eb6d63a 100644
--- a/react-app/src/features/shell/Settings.tsx
+++ b/react-app/src/features/shell/Settings.tsx
@@ -1,4 +1,142 @@
-/** Placeholder — Shell child ports Angular `core/settings` (popup driven by `useSettings()`). */
+import { useEffect, useId, useRef, type KeyboardEvent } from 'react';
+
+import { useSettings } from '../../shared/settings';
+import './Settings.scss';
+
+const THEME_OPTIONS = [
+ { value: 'default', label: 'Default' },
+ { value: 'night', label: 'Night' },
+ { value: 'amoledblack', label: 'Black (AMOLED)' },
+] as const;
+
+const FOCUSABLE = 'a[href], button, input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
+
+/**
+ * Port of `core/settings`: full-screen `.overlay` with the settings popup. Only × (or Escape) closes it;
+ * the overlay sits above the header, so a real second click on the cog lands on the overlay (Angular parity).
+ */
export function Settings() {
- return Settings placeholder
;
+ const { settings, toggleSettings, toggleOpenLinksInNewTab, setTheme, setFont, setSpacing } = useSettings();
+ const popupRef = useRef(null);
+ const closeRef = useRef(null);
+ const titleId = useId();
+
+ useEffect(() => {
+ closeRef.current?.focus();
+ }, []);
+
+ const focusables = () => {
+ const nodes = popupRef.current?.querySelectorAll(FOCUSABLE) ?? [];
+ // Only the checked radio of a group is in the Tab order.
+ return Array.from(nodes).filter(
+ (el) => !(el instanceof HTMLInputElement && el.type === 'radio' && !el.checked)
+ );
+ };
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ toggleSettings();
+ return;
+ }
+ if (event.key !== 'Tab') return;
+ const items = focusables();
+ if (items.length === 0) return;
+ const first = items[0];
+ const last = items[items.length - 1];
+ const active = document.activeElement;
+ const inside = active instanceof HTMLElement && items.includes(active);
+ if (event.shiftKey && (active === first || !inside)) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && (active === last || !inside)) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+
+ const onCloseKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ toggleSettings();
+ }
+ };
+
+ return (
+
+ );
}
diff --git a/react-app/src/pwa/registerSW.ts b/react-app/src/pwa/registerSW.ts
index 913beaf26..2a385b8bf 100644
--- a/react-app/src/pwa/registerSW.ts
+++ b/react-app/src/pwa/registerSW.ts
@@ -1,2 +1,8 @@
-/** Placeholder — the PWA child registers the vite-plugin-pwa service worker here. Called once from main.tsx. */
-export function registerServiceWorker(): void {}
+import { registerSW } from 'virtual:pwa-register';
+
+import { setupServiceWorker } from './setupServiceWorker';
+
+/** Registers the vite-plugin-pwa service worker (auto-update). Called once from main.tsx. */
+export function registerServiceWorker(): void {
+ setupServiceWorker(registerSW);
+}
diff --git a/react-app/src/pwa/runtimeCache.test.ts b/react-app/src/pwa/runtimeCache.test.ts
new file mode 100644
index 000000000..a9aab9af3
--- /dev/null
+++ b/react-app/src/pwa/runtimeCache.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest';
+
+import { HN_API_BASE_URL, HNPWA_API_BASE_URL } from '../shared/api';
+import { FEED_TYPES } from '../shared/models';
+import { API_CACHES, APP_ROUTE_PREFIXES, escapeRegExp, isRuntimeCachedUrl, STATIC_FILE_PATTERN } from './runtimeCache';
+
+describe('runtimeCache', () => {
+ it('caches both API origins the shared client calls', () => {
+ const origins = API_CACHES.map(({ origin }) => origin);
+ expect(origins).toContain(new URL(HN_API_BASE_URL).origin);
+ expect(origins).toContain(new URL(HNPWA_API_BASE_URL).origin);
+ });
+
+ it('matches API URLs only on the exact origin', () => {
+ expect(isRuntimeCachedUrl('https://node-hnapi.herokuapp.com/news?page=1')).toBe(true);
+ expect(isRuntimeCachedUrl('https://api.hnpwa.com/v0/user/pg.json')).toBe(true);
+ expect(isRuntimeCachedUrl('https://node-hnapi.herokuapp.com.evil.test/news')).toBe(false);
+ expect(isRuntimeCachedUrl('http://localhost:4173/assets/index.js')).toBe(false);
+ });
+
+ it('denies the SPA fallback only for static files', () => {
+ for (const path of [
+ '/assets/index-abc.js',
+ '/manifest.webmanifest',
+ '/favicon.ico',
+ '/sw.js',
+ '/a/b.PNG',
+ '/assets/images/logo.svg?v=2',
+ '/users/x.json',
+ ]) {
+ expect(STATIC_FILE_PATTERN.test(path), path).toBe(true);
+ }
+ for (const path of [
+ '/news/1',
+ '/item/8863',
+ '/user/a.b',
+ '/user/john.doe',
+ '/user/john.json',
+ '/user/x.png?ref=1',
+ '/',
+ '/?utm_source=web_app_manifest',
+ '/a.b/c',
+ ]) {
+ expect(STATIC_FILE_PATTERN.test(path), path).toBe(false);
+ }
+ });
+
+ it('exempts every client route prefix', () => {
+ expect([...APP_ROUTE_PREFIXES].sort()).toEqual([...FEED_TYPES, 'item', 'user'].sort());
+ });
+
+ it('escapes regular-expression characters', () => {
+ const pattern = new RegExp(`^${escapeRegExp('https://api.hnpwa.com')}/`);
+ expect(pattern.test('https://api.hnpwa.com/v0/news/1.json')).toBe(true);
+ expect(pattern.test('https://apiXhnpwa.com/v0/news/1.json')).toBe(false);
+ });
+});
diff --git a/react-app/src/pwa/runtimeCache.ts b/react-app/src/pwa/runtimeCache.ts
new file mode 100644
index 000000000..7806f8020
--- /dev/null
+++ b/react-app/src/pwa/runtimeCache.ts
@@ -0,0 +1,51 @@
+/** API origins the service worker caches at runtime (NetworkFirst). Shared with vite.config.ts. */
+export const API_CACHES = [
+ { origin: 'https://node-hnapi.herokuapp.com', cacheName: 'hn-api', maxEntries: 200 },
+ { origin: 'https://api.hnpwa.com', cacheName: 'hnpwa-api', maxEntries: 100 },
+] as const;
+
+export const API_CACHE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
+
+/** Static files that must never get the SPA index.html fallback (deep links like `/user/a.b` still do). */
+export const STATIC_FILE_EXTENSIONS = [
+ 'js',
+ 'mjs',
+ 'css',
+ 'map',
+ 'json',
+ 'webmanifest',
+ 'txt',
+ 'xml',
+ 'ico',
+ 'png',
+ 'jpg',
+ 'jpeg',
+ 'gif',
+ 'svg',
+ 'webp',
+ 'avif',
+ 'woff',
+ 'woff2',
+ 'ttf',
+ 'otf',
+ 'eot',
+ 'cur',
+ 'ani',
+] as const;
+
+/** First path segments of client routes (src/app/routes.tsx); their params may contain dots, e.g. `/user/john.json`. */
+export const APP_ROUTE_PREFIXES = ['news', 'newest', 'show', 'ask', 'jobs', 'item', 'user'] as const;
+
+/** Workbox tests navigateFallbackDenylist against `pathname + search`. */
+export const STATIC_FILE_PATTERN = new RegExp(
+ `^(?!/(?:${APP_ROUTE_PREFIXES.join('|')})/)[^?]*\\.(?:${STATIC_FILE_EXTENSIONS.join('|')})(?:\\?.*)?$`,
+ 'i'
+);
+
+export function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+export function isRuntimeCachedUrl(url: string): boolean {
+ return API_CACHES.some(({ origin }) => url.startsWith(`${origin}/`));
+}
diff --git a/react-app/src/pwa/setupServiceWorker.test.ts b/react-app/src/pwa/setupServiceWorker.test.ts
new file mode 100644
index 000000000..73b759c00
--- /dev/null
+++ b/react-app/src/pwa/setupServiceWorker.test.ts
@@ -0,0 +1,217 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { RegisterSWOptions } from 'virtual:pwa-register';
+
+import { IN_FLIGHT_WINDOW_MS, setupServiceWorker, warmRuntimeCache, type RegisterSW } from './setupServiceWorker';
+
+const FEED = 'https://node-hnapi.herokuapp.com/news?page=1';
+const entry = (name: string, startTime = 0) => ({ name, startTime }) as PerformanceEntry;
+
+function stubPerformanceObserver() {
+ const observer = {
+ callback: undefined as PerformanceObserverCallback | undefined,
+ observe: vi.fn(),
+ disconnect: vi.fn(),
+ };
+ vi.stubGlobal(
+ 'PerformanceObserver',
+ class {
+ observe = observer.observe;
+ disconnect = observer.disconnect;
+ constructor(callback: PerformanceObserverCallback) {
+ observer.callback = callback;
+ }
+ }
+ );
+ const emit = (entries: PerformanceEntry[]) =>
+ observer.callback?.({ getEntries: () => entries } as PerformanceObserverEntryList, {} as PerformanceObserver);
+ return { observer, emit };
+}
+
+function setup({ serviceWorker = true, onLine = true } = {}) {
+ const listeners: Array<() => void> = [];
+ const addEventListener = vi.fn((_type: string, listener: () => void) => listeners.push(listener));
+ if (serviceWorker) {
+ Object.defineProperty(navigator, 'serviceWorker', {
+ configurable: true,
+ value: { controller: null, addEventListener },
+ });
+ }
+ vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(onLine);
+ let options: RegisterSWOptions = {};
+ const updateSW = vi.fn();
+ const register = vi.fn((opts = {}) => {
+ options = opts;
+ return updateSW;
+ });
+ const logger = { info: vi.fn(), error: vi.fn() };
+ const result = setupServiceWorker(register, { updateCheckIntervalMs: 1000, logger });
+ const controllerChange = () => listeners.forEach((listener) => listener());
+ return { register, updateSW, logger, result, addEventListener, controllerChange, options: () => options };
+}
+
+function fakeRegistration(overrides: Partial = {}) {
+ return {
+ installing: null,
+ update: vi.fn().mockResolvedValue(undefined),
+ ...overrides,
+ } as unknown as ServiceWorkerRegistration;
+}
+
+describe('setupServiceWorker', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ Reflect.deleteProperty(navigator, 'serviceWorker');
+ });
+
+ it('does nothing when service workers are unsupported', () => {
+ const { register, result } = setup({ serviceWorker: false });
+ expect(register).not.toHaveBeenCalled();
+ expect(result).toBeUndefined();
+ });
+
+ it('registers immediately and returns the update function', () => {
+ const { register, updateSW, result, options } = setup();
+ expect(register).toHaveBeenCalledTimes(1);
+ expect(options().immediate).toBe(true);
+ expect(result).toBe(updateSW);
+ });
+
+ it('re-requests API responses that started before the SW took control, including in-flight ones', () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response('[]'));
+ vi.stubGlobal('fetch', fetchMock);
+ vi.spyOn(performance, 'now').mockReturnValue(100);
+ vi.spyOn(performance, 'getEntriesByType').mockReturnValue([
+ entry(FEED, 10),
+ entry('http://localhost:4173/assets/index.js', 5),
+ ]);
+ const { observer, emit } = stubPerformanceObserver();
+ const { addEventListener, controllerChange } = setup();
+
+ expect(addEventListener).toHaveBeenCalledWith('controllerchange', expect.any(Function));
+ expect(fetchMock).not.toHaveBeenCalled();
+
+ controllerChange();
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([FEED]);
+ expect(observer.observe).toHaveBeenCalledWith({ type: 'resource' });
+
+ // In flight at control time → re-requested; started after control → already went through the SW.
+ emit([entry(FEED, 10), entry('https://node-hnapi.herokuapp.com/item/1', 50), entry('https://x.test/a', 60)]);
+ emit([entry('https://node-hnapi.herokuapp.com/item/2', 150)]);
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([FEED, 'https://node-hnapi.herokuapp.com/item/1']);
+
+ vi.advanceTimersByTime(IN_FLIGHT_WINDOW_MS);
+ expect(observer.disconnect).toHaveBeenCalled();
+ });
+
+ it('warms again on a later controller change (SW update) without repeating URLs', () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response('[]'));
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubGlobal('PerformanceObserver', undefined);
+ vi.spyOn(performance, 'now').mockReturnValue(100);
+ const entries = vi.spyOn(performance, 'getEntriesByType').mockReturnValue([entry(FEED)]);
+ const { controllerChange } = setup();
+
+ controllerChange();
+ entries.mockReturnValue([entry(FEED), entry('https://api.hnpwa.com/v0/user/pg.json')]);
+ controllerChange();
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([FEED, 'https://api.hnpwa.com/v0/user/pg.json']);
+ });
+
+ it('polls for a new service worker while online', async () => {
+ const { options } = setup();
+ const registration = fakeRegistration();
+ options().onRegisteredSW?.('/sw.js', registration);
+
+ expect(registration.update).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(registration.update).toHaveBeenCalledTimes(1);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(registration.update).toHaveBeenCalledTimes(2);
+ });
+
+ it('skips update checks while offline or while a worker is installing', async () => {
+ const offline = setup({ onLine: false });
+ const registration = fakeRegistration();
+ offline.options().onRegisteredSW?.('/sw.js', registration);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(registration.update).not.toHaveBeenCalled();
+
+ vi.clearAllTimers();
+ const installing = setup();
+ const busy = fakeRegistration({ installing: {} as ServiceWorker });
+ installing.options().onRegisteredSW?.('/sw.js', busy);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(busy.update).not.toHaveBeenCalled();
+ });
+
+ it('does not poll without a registration', () => {
+ const { options } = setup();
+ expect(() => options().onRegisteredSW?.('/sw.js', undefined)).not.toThrow();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('logs failed update checks instead of throwing', async () => {
+ const { options, logger } = setup();
+ const error = new Error('network');
+ options().onRegisteredSW?.('/sw.js', fakeRegistration({ update: vi.fn().mockRejectedValue(error) }));
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(logger.error).toHaveBeenCalledWith('Service worker update failed', error);
+ });
+
+ it('logs offline-ready and registration errors', () => {
+ const { options, logger } = setup();
+ options().onOfflineReady?.();
+ expect(logger.info).toHaveBeenCalledWith('App is ready to work offline.');
+ const error = new Error('blocked');
+ options().onRegisterError?.(error);
+ expect(logger.error).toHaveBeenCalledWith('Service worker registration failed', error);
+ });
+});
+
+describe('warmRuntimeCache', () => {
+ it('fetches each runtime-cached API URL once and ignores other resources', async () => {
+ const entries = [
+ FEED,
+ FEED,
+ 'https://api.hnpwa.com/v0/user/pg.json',
+ 'http://localhost:4200/assets/index.js',
+ 'https://node-hnapi.herokuapp.com.evil.test/news',
+ ].map((name) => entry(name));
+ const fetchMock = vi.fn().mockResolvedValue(new Response('{}'));
+ const warmed = new Set();
+
+ await warmRuntimeCache(entries, fetchMock, warmed);
+ await warmRuntimeCache(entries, fetchMock, warmed);
+
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([FEED, 'https://api.hnpwa.com/v0/user/pg.json']);
+ });
+
+ it('settles when a request fails', async () => {
+ const fetchMock = vi.fn().mockRejectedValue(new TypeError('offline'));
+ await expect(warmRuntimeCache([entry(FEED)], fetchMock)).resolves.toEqual([
+ { status: 'rejected', reason: expect.any(TypeError) },
+ ]);
+ });
+
+ it('retries URLs whose warm-up failed or returned a non-OK response', async () => {
+ const USER = 'https://api.hnpwa.com/v0/user/pg.json';
+ const fetchMock = vi
+ .fn()
+ .mockRejectedValueOnce(new TypeError('offline'))
+ .mockResolvedValueOnce(new Response('', { status: 503 }))
+ .mockResolvedValue(new Response('{}'));
+ const warmed = new Set();
+
+ await warmRuntimeCache([entry(FEED), entry(USER)], fetchMock, warmed);
+ expect(warmed.size).toBe(0);
+ await warmRuntimeCache([entry(FEED), entry(USER)], fetchMock, warmed);
+ await warmRuntimeCache([entry(FEED), entry(USER)], fetchMock, warmed);
+
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([FEED, USER, FEED, USER]);
+ expect([...warmed]).toEqual([FEED, USER]);
+ });
+});
diff --git a/react-app/src/pwa/setupServiceWorker.ts b/react-app/src/pwa/setupServiceWorker.ts
new file mode 100644
index 000000000..63a1ca0e0
--- /dev/null
+++ b/react-app/src/pwa/setupServiceWorker.ts
@@ -0,0 +1,93 @@
+import type { RegisterSWOptions } from 'virtual:pwa-register';
+
+import { isRuntimeCachedUrl } from './runtimeCache';
+
+export type RegisterSW = (options?: RegisterSWOptions) => (reloadPage?: boolean) => Promise;
+
+export const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
+/** How long after a controller change in-flight API requests are still re-requested through the SW. */
+export const IN_FLIGHT_WINDOW_MS = 60 * 1000;
+
+export interface SetupOptions {
+ updateCheckIntervalMs?: number;
+ logger?: Pick;
+}
+
+/**
+ * Re-requests runtime-cached API URLs (once each per `warmed` set) so they go through the SW's cache.
+ * Failed or non-OK requests are not cached by the SW, so they are unmarked and retried next time.
+ */
+export function warmRuntimeCache(
+ entries: PerformanceEntryList,
+ fetchFn: typeof fetch = fetch,
+ warmed = new Set()
+): Promise[]> {
+ const urls = [...new Set(entries.map((entry) => entry.name))].filter(
+ (url) => isRuntimeCachedUrl(url) && !warmed.has(url)
+ );
+ urls.forEach((url) => warmed.add(url));
+ return Promise.allSettled(
+ urls.map(async (url) => {
+ try {
+ const response = await fetchFn(url);
+ if (!response.ok) warmed.delete(url);
+ return response;
+ } catch (error) {
+ warmed.delete(url);
+ throw error;
+ }
+ })
+ );
+}
+
+/**
+ * The first visit's API requests start before the SW controls the page, so they never reach its runtime
+ * cache and the landing page would fail offline. On every controller change, request them again through
+ * the SW — including requests still in flight at that moment (PerformanceObserver).
+ */
+function warmRuntimeCacheOnControllerChange(container: ServiceWorkerContainer): void {
+ const warmed = new Set();
+ container.addEventListener('controllerchange', () => {
+ const controlledAt = performance.now();
+ const startedBeforeControl = (entries: PerformanceEntryList) =>
+ entries.filter((entry) => entry.startTime < controlledAt);
+
+ void warmRuntimeCache(startedBeforeControl(performance.getEntriesByType('resource')), fetch, warmed);
+ if (typeof PerformanceObserver === 'undefined') return;
+ const observer = new PerformanceObserver(
+ (list) => void warmRuntimeCache(startedBeforeControl(list.getEntries()), fetch, warmed)
+ );
+ observer.observe({ type: 'resource' });
+ setTimeout(() => observer.disconnect(), IN_FLIGHT_WINDOW_MS);
+ });
+}
+
+/**
+ * Registers the generated service worker. It auto-updates (skipWaiting + clientsClaim in the Workbox
+ * config); long-lived tabs also poll for a new version while online.
+ */
+export function setupServiceWorker(
+ register: RegisterSW,
+ { updateCheckIntervalMs = UPDATE_CHECK_INTERVAL_MS, logger = console }: SetupOptions = {}
+): ((reloadPage?: boolean) => Promise) | undefined {
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return undefined;
+
+ warmRuntimeCacheOnControllerChange(navigator.serviceWorker);
+
+ return register({
+ immediate: true,
+ onRegisteredSW(_swUrl, registration) {
+ if (!registration) return;
+ setInterval(() => {
+ if (registration.installing || !navigator.onLine) return;
+ registration.update().catch((error: unknown) => logger.error('Service worker update failed', error));
+ }, updateCheckIntervalMs);
+ },
+ onOfflineReady() {
+ logger.info('App is ready to work offline.');
+ },
+ onRegisterError(error: unknown) {
+ logger.error('Service worker registration failed', error);
+ },
+ });
+}
diff --git a/react-app/vite.config.ts b/react-app/vite.config.ts
index 26dc3244a..a8243fc88 100644
--- a/react-app/vite.config.ts
+++ b/react-app/vite.config.ts
@@ -1,11 +1,66 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
+import { VitePWA } from 'vite-plugin-pwa';
+
+import { API_CACHE_MAX_AGE_SECONDS, API_CACHES, escapeRegExp, STATIC_FILE_PATTERN } from './src/pwa/runtimeCache';
// Owned by Workstream 4 (shell, theming & PWA). Unit tests use vitest.config.ts and never load this file.
export default defineConfig({
plugins: [
react(),
- // --- PWA plugin goes here (vite-plugin-pwa `VitePWA({...})`, owned by the PWA child) ---
+ // Port of Angular's ngsw-config.json:
+ // - "app" group (prefetch: favicon, index.html, *.css, *.js, manifest.webmanifest) → Workbox precache,
+ // plus the app-shell images (header logo, cog) so the shell renders offline after the first visit.
+ // - "assets" group (lazy install, prefetch updates) → StaleWhileRevalidate runtime cache for /assets/**
+ // and other same-origin images/fonts.
+ // - New: NetworkFirst runtime caching of the HN APIs so visited feeds/items/users work offline.
+ VitePWA({
+ strategies: 'generateSW',
+ registerType: 'autoUpdate',
+ // src/pwa/registerSW.ts registers the SW itself (virtual:pwa-register), called from main.tsx.
+ injectRegister: false,
+ // public/manifest.webmanifest is the port of Angular's src/manifest.json; index.html links it.
+ manifest: false,
+ // Never combine includeAssets/manifest icons with globPatterns: duplicate precache URLs make Workbox
+ // throw add-to-cache-list-conflicting-entries and the SW silently caches nothing.
+ includeManifestIcons: false,
+ workbox: {
+ globPatterns: ['**/*.{js,css,html,ico,webmanifest}', 'assets/images/*.{svg,png}'],
+ // Retires the Angular service worker for returning visitors; must never be precached.
+ globIgnores: ['ngsw-worker.js'],
+ navigateFallback: 'index.html',
+ // Firebase reserved URLs and static files must not get the app shell.
+ navigateFallbackDenylist: [/^\/__\//, STATIC_FILE_PATTERN],
+ cleanupOutdatedCaches: true,
+ // autoUpdate only sets these itself with injectRegister 'auto'.
+ skipWaiting: true,
+ clientsClaim: true,
+ runtimeCaching: [
+ ...API_CACHES.map(({ origin, cacheName, maxEntries }) => ({
+ urlPattern: new RegExp(`^${escapeRegExp(origin)}/`),
+ handler: 'NetworkFirst' as const,
+ options: {
+ cacheName,
+ networkTimeoutSeconds: 5,
+ expiration: { maxEntries, maxAgeSeconds: API_CACHE_MAX_AGE_SECONDS },
+ cacheableResponse: { statuses: [200] },
+ },
+ })),
+ {
+ urlPattern: ({ url, sameOrigin }) =>
+ sameOrigin &&
+ (url.pathname.startsWith('/assets/') ||
+ /\.(?:eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)$/i.test(url.pathname)),
+ handler: 'StaleWhileRevalidate',
+ options: {
+ cacheName: 'assets',
+ expiration: { maxEntries: 60 },
+ cacheableResponse: { statuses: [200] },
+ },
+ },
+ ],
+ },
+ }),
],
css: {
preprocessorOptions: {