Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion .github/workflows/react-app-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
32 changes: 32 additions & 0 deletions react-app/e2e/app.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
118 changes: 118 additions & 0 deletions react-app/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>Fixture comment body</p>',
comments: [
{
id: 9225,
level: 1,
user: 'reply-user',
time: 1175727300,
time_ago: '15 years ago',
content: '<p>Fixture reply body</p>',
comments: [],
},
],
},
],
};

export const user = {
id: 'pg',
created_time: 1160418092,
created: '17 years ago',
karma: 155111,
about: '<p>Fixture about text</p>',
};

/** 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;
}
87 changes: 87 additions & 0 deletions react-app/e2e/offline.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/// <reference lib="dom" />
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.');
});
Loading
Loading