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(