Repository navigation
feature: PWA (vite-plugin-pwa), Firebase Hosting and GitHub Actions CI for the React app #883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
devin-ai-integration
merged 10 commits into
devin/1791469878-react-phase0-foundation
from
devin/1791470086-react-pwa-build-deploy
Oct 8, 2026
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
4b5aec7
feature: PWA (vite-plugin-pwa), Firebase hosting and GitHub Actions C…
devin-ai-integration[bot] 615de8c
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] a9d7056
feature: no-cache for the root URL on Firebase, add robots.txt
devin-ai-integration[bot] edab23c
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] a7c32c9
feature: assert the feed renders from the SW cache offline
devin-ai-integration[bot] b18597d
feature: warm the SW runtime cache with API requests made before it t…
devin-ai-integration[bot] 1127f1d
feature: document first-visit runtime cache warm-up
devin-ai-integration[bot] dc71ecf
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] aa0b0b4
feature: offline e2e visits the item page online and asserts it rende…
devin-ai-integration[bot] 099752d
bug: retire the Angular ngsw worker for returning visitors and dedupe…
devin-ai-integration[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| 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 | ||
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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$/); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.ymlwith CI forreact-app/. Firebase Hosting now servesreact-app/dist, so the Angular build is no longer what ships. The old Travis job only rannpm run buildon Node 6.9, which no longer runs Angular 9 without--openssl-legacy-provider. Whether to keep an Angular build check untilsrc/is deleted is the integrator's decision, so I'm leaving this thread open.Written by Devin