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
100 changes: 100 additions & 0 deletions .github/workflows/react-app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: React app

on:
push:
branches: [master]
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

defaults:
run:
working-directory: react-app

jobs:
check:
name: Lint, typecheck, test, build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: react-app/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
- run: npm run format:check
Comment on lines +31 to +36

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Angular build loses CI coverage

The new workflow checks only the React app while the Angular app remains in the repository. Confirm whether Angular build checks still belong in CI during the migration.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional for this workstream: the brief asks to replace .travis.yml with CI for react-app/. Firebase Hosting now serves react-app/dist, so the Angular build is no longer what ships. The old Travis job only ran npm run build on Node 6.9, which no longer runs Angular 9 without --openssl-legacy-provider. Whether to keep an Angular build check until src/ is deleted is the integrator's decision, so I'm leaving this thread open.

Written by Devin


e2e:
name: Playwright (offline / PWA)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: react-app/package-lock.json
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: |
react-app/playwright-report
react-app/test-results
retention-days: 7

deploy:
name: Deploy to Firebase Hosting
needs: [check, e2e]
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
defaults:
run:
working-directory: .
steps:
- name: Check for Firebase credentials
id: creds
env:
FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
run: |
[ -n "$FIREBASE_SERVICE_ACCOUNT" ] && echo "service_account=true" >> "$GITHUB_OUTPUT"
[ -n "$FIREBASE_TOKEN" ] && echo "token=true" >> "$GITHUB_OUTPUT"
if [ -z "$FIREBASE_SERVICE_ACCOUNT" ] && [ -z "$FIREBASE_TOKEN" ]; then
echo "::notice::No FIREBASE_SERVICE_ACCOUNT or FIREBASE_TOKEN secret; skipping deploy."
fi
- uses: actions/checkout@v4
if: steps.creds.outputs.service_account == 'true' || steps.creds.outputs.token == 'true'
- uses: actions/setup-node@v4
if: steps.creds.outputs.service_account == 'true' || steps.creds.outputs.token == 'true'
with:
node-version: 20
cache: npm
cache-dependency-path: react-app/package-lock.json
# firebase.json's hosting predeploy runs `npm ci` + `npm run build` in react-app/.
- name: Deploy (service account)
if: steps.creds.outputs.service_account == 'true'
uses: FirebaseExtended/action-hosting-deploy@v0
with:
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
projectId: angular2-hn
channelId: live
- name: Deploy (token)
if: steps.creds.outputs.service_account != 'true' && steps.creds.outputs.token == 'true'
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
run: npx --yes firebase-tools@15 deploy --only hosting --project default --non-interactive
23 changes: 0 additions & 23 deletions .travis.yml

This file was deleted.

18 changes: 17 additions & 1 deletion firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,28 @@
"rules": "database.rules.json"
},
"hosting": {
"public": "dist",
"public": "react-app/dist",
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"predeploy": ["npm --prefix \"$PROJECT_DIR/react-app\" ci", "npm --prefix \"$PROJECT_DIR/react-app\" run build"],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
],
"headers": [
{
"regex": "^/assets/[^/]+-[A-Za-z0-9_-]{8}\\.[a-z0-9]+$",
"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": "{/,**/!(*.*)}",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
}
]
}
}
37 changes: 37 additions & 0 deletions react-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,40 @@ Requires Node >= 20.19.
- **Links**: use `<Link>` from `react-router` for internal routes (`/item/:id`, `/user/:id`, `/:feed/:page`). For `routerLinkActive="active"`, use `<NavLink>` (it adds `active`).
- **Tests**: Vitest + Testing Library, `*.test.tsx` next to the component. Use the helpers in `src/test`: `renderApp({ route })` (full route tree), `renderWithProviders(ui, { path, route, settings, handle })`, `mockFetch({ 'url-substring': body | { status, body } })`, and the fixtures in `test/fixtures.ts`.
- **Formatting**: Prettier settings are copied from the root `package.json` (4 spaces, single quotes, 120 cols, es5 trailing commas).

## Build & deploy

### PWA

`vite-plugin-pwa` (`generateSW`, `registerType: 'autoUpdate'`) is configured in `vite.config.ts` and only runs on `npm run build`; the dev server has no service worker.

- **Manifest**: generated as `dist/manifest.webmanifest` from the same fields as the Angular `src/manifest.json`. The plugin adds `<link rel="manifest">` to `index.html`.
- **Precache**: JS/CSS/HTML/ico/png/svg in `dist/` (the app shell, lazy route chunks and `public/assets` icons) plus the manifest. Only `workbox.globPatterns` selects files. Don't add `includeAssets`, and keep `includeManifestIcons: false`: a URL listed twice makes Workbox throw `add-to-cache-list-conflicting-entries`, and the SW installs but caches nothing. Check `dist/sw.js` after changing the config.
- **Deep links**: `navigateFallback: 'index.html'`, so `/item/123` loads offline.
- **API**: `node-hnapi.herokuapp.com` and `api.hnpwa.com` use NetworkFirst (5 s timeout, 7 day / 200 + 100 entry cap). Pages visited online work offline.
- **Registration**: `src/pwa/index.ts` (imported by `main.tsx`) calls `registerServiceWorker` from `src/pwa/registerSW.ts`. The new SW activates immediately (`skipWaiting` + `clientsClaim`), and open tabs check for an update every hour while online. On the first visit the API requests start before the SW controls the page, so once it takes control `registerSW` re-requests them (from `performance` resource entries) through the SW; otherwise the page you landed on would not work offline.
- **Retiring the Angular SW**: returning visitors still have `@angular/service-worker` registered at `/ngsw-worker.js`. `public/ngsw-worker.js` is a safety worker served at that URL (excluded from the precache, `no-cache` on Firebase): on the browser's next update check it replaces the old worker, deletes the `ngsw:*` caches, unregisters itself and reloads open tabs, which then register `sw.js`.

### E2E

`npm run e2e` builds the app and runs Playwright against `vite preview` on port 4173. Service workers are blocked by default, so `page.route` mocks keep working. `e2e/offline.spec.ts` opts back in with `test.use({ serviceWorkers: 'allow' })`. `playwright.config.ts` sets `PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` so `context.route` also sees the SW's fetches. `context.setOffline(true)` doesn't stop the SW's own fetches, so the test also aborts every route. If `npx playwright install` is blocked, the config uses the system Chrome under `/opt/.devin/chrome` or `PLAYWRIGHT_CHROME_PATH`.

### Firebase Hosting

`firebase.json` (repo root) serves `react-app/dist`:

- `predeploy` runs `npm ci` + `npm run build` in `react-app/`.
- SPA rewrite `** → /index.html`.
- `Cache-Control: no-cache` for `sw.js`, `index.html`, `manifest.webmanifest` and extensionless SPA routes.
- `public, max-age=31536000, immutable` for Vite's hashed files (`/assets/<name>-<8 char hash>.<ext>`). The unhashed icons in `/assets/icons` and `/assets/images` keep Firebase's default caching.
- The `database` rules entry is unchanged.

Manual deploy from the repo root: `npx firebase-tools deploy --only hosting` (project aliases are in `.firebaserc`).

### CI

`.github/workflows/react-app.yml` replaces `.travis.yml`:

- **check**: on PRs and pushes to `master`, runs `npm ci`, lint, typecheck, test, build and format:check on Node 20 with the npm cache.
- **e2e**: runs the Playwright offline/PWA tests and uploads the report if they fail.
- **deploy**: on pushes to `master`, after both jobs pass. It uses `FirebaseExtended/action-hosting-deploy` if the `FIREBASE_SERVICE_ACCOUNT` secret is set, or falls back to `firebase-tools` with `FIREBASE_TOKEN`. With neither secret it skips with a notice, so forks don't fail.
147 changes: 147 additions & 0 deletions react-app/e2e/offline.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/// <reference lib="dom" />
import { expect, test, type BrowserContext, type Page } from '@playwright/test';

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: 71,
type: 'link',
url: 'http://www.getdropbox.com/u/2/screencast.html',
domain: 'getdropbox.com',
};
const FEED_URL = 'https://node-hnapi.herokuapp.com/news?page=1';
const ITEM_URL = 'https://node-hnapi.herokuapp.com/item/8863';

test.use({ serviceWorkers: 'allow' });

// Slow enough that the first feed request is still in flight when the SW takes control.
const API_LATENCY_MS = 1500;

async function mockApi(context: BrowserContext) {
await context.route('https://node-hnapi.herokuapp.com/**', async (route) => {
await new Promise((resolve) => setTimeout(resolve, API_LATENCY_MS));
const { pathname } = new URL(route.request().url());
const body = pathname.startsWith('/item/') ? { ...story, comments: [], content: '' } : [story];
return route.fulfill({ json: body, headers: { 'access-control-allow-origin': '*' } });
});
}

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 })
);
}
});
}

// setOffline doesn't cover the SW's own fetches and route mocks keep answering while offline,
// so swap the mocks for a route that fails every request (SW fetches included).
async function goOffline(context: BrowserContext) {
await context.unrouteAll();
await context.route('**/*', (route) => route.abort('internetdisconnected'));
await context.setOffline(true);
}

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);
}

function fetchJson(page: Page, url: string) {
return page.evaluate(async (u) => (await fetch(u)).json(), url);
}

test('app shell, deep links and visited API responses work offline', async ({ context, page }) => {
await mockApi(context);

await page.goto('/news/1');
await expect(page.locator('#root .main-content')).toBeVisible();
await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible();
await waitForServiceWorkerControl(page);

// First visit: the feed request started before the SW took control, so registerSW re-requests it.
await expect.poll(() => isCached(page, 'hn-api', FEED_URL)).toBe(true);
// Visit the item page client-side (through the SW) so its API response is cached too.
await page.getByRole('link', { name: `${story.comments_count} comments` }).click();
await expect(page).toHaveURL(/\/item\/8863$/);
await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible();
await page.goBack();

await goOffline(context);

const reload = await page.reload();
expect(reload?.fromServiceWorker()).toBe(true);
await expect(page.locator('#root .main-content')).toBeVisible();
// The feed renders from the SW's runtime cache.
await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible();
await expect(page).toHaveTitle('Angular 2 HN');
expect(await fetchJson(page, FEED_URL)).toEqual([story]);

// Deep link that was never loaded as a document: served by navigateFallback, lazy chunk from precache.
const deepLink = await page.goto('/item/8863');
expect(deepLink?.fromServiceWorker()).toBe(true);
await expect(page).toHaveURL(/\/item\/8863$/);
await expect(page.locator('#root .main-content')).toBeVisible();
await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible();
expect(await fetchJson(page, ITEM_URL)).toMatchObject({ id: story.id, title: story.title });

// Pages never visited online have no cached API data.
await expect(fetchJson(page, 'https://node-hnapi.herokuapp.com/item/1')).rejects.toThrow();
});

test('serves a valid web app manifest', async ({ page }) => {
await page.goto('/news/1');
const href = await page.locator('link[rel="manifest"]').getAttribute('href');
expect(href).toBe('/manifest.webmanifest');
await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#b92b27');

const manifest = await (await page.request.get(href!)).json();
expect(manifest).toMatchObject({
name: 'Angular 2 HN',
short_name: 'Angular 2 HN',
theme_color: '#b92b27',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
start_url: './?utm_source=web_app_manifest',
});
expect(manifest.icons.map((icon: { sizes: string }) => icon.sizes)).toEqual([
'144x144',
'192x192',
'256x256',
'512x512',
]);
for (const icon of manifest.icons) {
expect((await page.request.get(icon.src)).ok()).toBe(true);
}
});

test('ngsw-worker.js retires a previously installed Angular service worker', async ({ page }) => {
await page.goto('/');
await waitForServiceWorkerControl(page);

// Simulate a returning Angular visitor: Angular caches plus a registration for ngsw-worker.js.
// The safety worker reloads the page once it has cleaned up.
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 page registers the React SW again.
await expect
.poll(() =>
page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.active?.scriptURL ?? '')
)
.toMatch(/\/sw\.js$/);
});
50 changes: 50 additions & 0 deletions react-app/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Let context.route intercept requests made by the service worker (offline/PWA tests).
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 = 4173;
const DEVIN_CHROME_DIR = '/opt/.devin/chrome/chrome';

// Falls back to the system Chrome when `npx playwright install` isn't available.
function systemChrome(): 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 skip page.route mocks. Offline tests opt back in.
serviceWorkers: 'block',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], launchOptions: { executablePath: systemChrome() } },
},
],
// Production build: the service worker is only generated by `vite build`.
webServer: {
command: `npm run build && npx vite preview --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
});
Loading
Loading