Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b539205
feat: add React 19 / Next 16 support (INTER-2317)
JuroUhlar Jul 17, 2026
1ff896f
fix: declare Node engine requirement in Next example apps
JuroUhlar Jul 17, 2026
f999113
chore: update CI workflow name for clarity
JuroUhlar Jul 17, 2026
ffc711f
fix: configure Next ESLint rootDir and drop stale Next 14 reference
JuroUhlar Jul 17, 2026
8717c3e
test(e2e): add Playwright e2e matrix for examples on React 18 & 19
claude Jul 17, 2026
25fa8a6
ci(e2e): source the region from the FPJS_REGION repo variable
claude Jul 19, 2026
3e2020a
ci(e2e): require FPJS_REGION with no default
claude Jul 19, 2026
9759b06
ci(e2e): accept the public API key as a secret or a variable
claude Jul 19, 2026
7902459
ci(e2e): serve the preact example from a build instead of watch
claude Jul 19, 2026
943e78e
ci(e2e): harden workflow validation
JuroUhlar Jul 19, 2026
1dd3d8d
test(e2e): harden visitor-id assertion and React 19 switch
JuroUhlar Jul 19, 2026
eda0ea9
chore: region param optional
JuroUhlar Jul 20, 2026
7dd5fe7
chore: clean up
JuroUhlar Jul 20, 2026
b4cfbc1
chore: tighten EXAMPLES key typing with satisfies
JuroUhlar Jul 20, 2026
bca2da5
test(e2e): validate production builds
JuroUhlar Jul 20, 2026
f83b75f
fix(examples): guard optional region env access
JuroUhlar Jul 20, 2026
c9b927f
chore: fold preact typecheck into example build
JuroUhlar Jul 20, 2026
c1cabef
fix(examples): drop deprecated TypeScript 6 compiler options
JuroUhlar Jul 20, 2026
8e2846b
fix: address Copilot review on example name check and env reads
JuroUhlar Jul 20, 2026
0e47870
chore: merge main into examples e2e branch
Copilot Jul 21, 2026
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
130 changes: 130 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: e2e

on:
push:
branches-ignore:
- main
paths-ignore:
- "**.md"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
e2e:
name: ${{ matrix.example }} (React ${{ matrix.react }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Every example is tested against each React version it supports. The
# preact example uses Preact (via preact/compat) rather than React, so
# it runs once outside the React matrix.
include:
- { example: create-react-app, react: "18" }
- { example: create-react-app, react: "19" }
- { example: vite, react: "18" }
- { example: vite, react: "19" }
- { example: webpack-based, react: "18" }
- { example: webpack-based, react: "19" }
- { example: next, react: "18" }
- { example: next, react: "19" }
- { example: next-appDir, react: "18" }
- { example: next-appDir, react: "19" }
- { example: preact, react: "preact" }
env:
# A single FPJS_PUBLIC_API_KEY feeds every framework's public-key variable
# (each example reads whichever one its bundler exposes). The key is a
# Fingerprint *public* key, so it works as either a repo secret or a repo
# variable.
VITE_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }}
REACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }}
NEXT_PUBLIC_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }}
PREACT_APP_FPJS_PUBLIC_API_KEY: ${{ secrets.FPJS_PUBLIC_API_KEY || vars.FPJS_PUBLIC_API_KEY }}
# Optional region of the CI key, from the FPJS_REGION repo variable (us/eu/ap).
VITE_FPJS_REGION: ${{ vars.FPJS_REGION }}
REACT_APP_FPJS_REGION: ${{ vars.FPJS_REGION }}
NEXT_PUBLIC_FPJS_REGION: ${{ vars.FPJS_REGION }}
PREACT_APP_FPJS_REGION: ${{ vars.FPJS_REGION }}
steps:
- uses: actions/checkout@v4

- name: Ensure the API key and region are configured
run: |
if [ -z "$VITE_FPJS_PUBLIC_API_KEY" ]; then
echo "::error::FPJS_PUBLIC_API_KEY is not set. Add a public API key as a repo secret or variable."
exit 1
fi
case "$VITE_FPJS_REGION" in
""|us|eu|ap) ;;
*)
echo "::error::FPJS_REGION must be us, eu, or ap when set."
exit 1
;;
esac

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm

- name: Switch the workspace to React 19
if: matrix.react == '19'
# Force React 19 for the SDK and every example via pnpm overrides (pnpm 11
# stores these in pnpm-workspace.yaml). Overrides beat the React 18 catalog
# so every `catalog:` consumer moves in lockstep — needed to avoid
# duplicate @types/react, which breaks the Next App Router type-check.
run: |
pnpm config set overrides --json '{
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}' --location project
echo "Overrides after switch:"
pnpm config get overrides --location project

- name: Install dependencies
run: pnpm install ${{ matrix.react == '19' && '--no-frozen-lockfile' || '--frozen-lockfile' }}

- name: Verify React major version
if: matrix.react == '18' || matrix.react == '19'
run: |
node -e '
const major = require("react/package.json").version.split(".")[0]
const expected = process.env.EXPECTED_REACT_MAJOR
if (major !== expected) {
console.error(`Expected React ${expected}, got ${require("react/package.json").version}`)
process.exit(1)
}
console.log(`React ${require("react/package.json").version}`)
'
env:
EXPECTED_REACT_MAJOR: ${{ matrix.react }}

- name: Build the SDK
run: pnpm build

- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium

- name: Run e2e tests
run: pnpm test:e2e
env:
EXAMPLE: ${{ matrix.example }}

- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.example }}-react-${{ matrix.react }}
path: playwright-report/
retention-days: 7
if-no-files-found: ignore
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
# Coverage
/coverage/

# Playwright
/playwright-report/
/test-results/
/e2e/.cache/

# misc
.DS_Store
.env.local
Expand Down
43 changes: 43 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# End-to-end tests

Browser e2e tests that boot each [example app](../examples) with Playwright and
assert the Fingerprint React SDK identifies the visitor (a visitor ID is
rendered on the page).

## How it works

- [`examples.ts`](./examples.ts) is the single source of truth: it maps each
example directory to the build+serve command and the port it listens on.
- [`playwright.config.ts`](./playwright.config.ts) reads the `EXAMPLE`
environment variable, builds and serves that one example, and points the
tests at it.
- [`tests/example.spec.ts`](./tests/example.spec.ts) is framework-agnostic: it
loads the app and waits for `[data-testid="visitor-id"]` to show a visitor ID.

CI runs one example per job across a matrix of React versions
(see [`.github/workflows/e2e.yml`](../.github/workflows/e2e.yml)).

## Running locally

A live Fingerprint **public API key** is required. The key's region must match
`*_FPJS_REGION` (see each example's `.env.example`).

```bash
# from the repo root
pnpm install
pnpm build # build the SDK the examples consume

# point the examples at your key + region
export VITE_FPJS_PUBLIC_API_KEY=<key>
export REACT_APP_FPJS_PUBLIC_API_KEY=<key>
export NEXT_PUBLIC_FPJS_PUBLIC_API_KEY=<key>
export PREACT_APP_FPJS_PUBLIC_API_KEY=<key>
export VITE_FPJS_REGION=eu REACT_APP_FPJS_REGION=eu NEXT_PUBLIC_FPJS_REGION=eu PREACT_APP_FPJS_REGION=eu

pnpm exec playwright install chromium

EXAMPLE=vite pnpm test:e2e
```

Valid `EXAMPLE` values: `create-react-app`, `next`, `next-appDir`, `preact`,
`vite`, `webpack-based`.
66 changes: 66 additions & 0 deletions e2e/examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Single source of truth for the example apps exercised by the e2e suite.
*
* Each entry maps an example directory name (also used as the `EXAMPLE` env var
* and the CI matrix key) to how Playwright should start and reach it. A CI job
* runs exactly one example, selected via `EXAMPLE`, so ports only need to be
* unique enough to run locally side by side.
*
* Commands use pnpm path filters (`--filter ./examples/<dir>`) so the directory
* name is the only identifier needed here, in CI, and in the React-version
* override step.
*
* Each command is a production `build` followed by that example's serve path
* (`start` / `preview` / `serve`), not `dev`. Typechecking, if any, lives in
* the example's own `build` script.
*/
export interface ExampleConfig {
/** Port the example server listens on. */
port: number
/** Command Playwright runs to build and serve the example (from the repo root). */
command: string
}

export const EXAMPLES = {
'create-react-app': {
port: 3001,
command: 'pnpm --filter ./examples/create-react-app build && pnpm --filter ./examples/create-react-app serve',
},
next: {
port: 3002,
command: 'pnpm --filter ./examples/next build && pnpm --filter ./examples/next start',
},
'next-appDir': {
port: 3003,
command: 'pnpm --filter ./examples/next-appDir build && pnpm --filter ./examples/next-appDir start',
},
preact: {
port: 8080,
command: 'pnpm --filter ./examples/preact build && pnpm --filter ./examples/preact serve',
},
vite: {
port: 5173,
command: 'pnpm --filter ./examples/vite build && pnpm --filter ./examples/vite preview',
},
'webpack-based': {
// JS-only example — its `build` does not typecheck.
port: 8081,
command: 'pnpm --filter ./examples/webpack-based build && pnpm --filter ./examples/webpack-based serve',
},
} as const satisfies Record<string, ExampleConfig>

export type ExampleName = keyof typeof EXAMPLES

function isExampleName(name: string): name is ExampleName {
return Object.hasOwn(EXAMPLES, name)
}

export function resolveExample(name: string | undefined): { name: ExampleName; config: ExampleConfig } {
if (name === undefined || name === '') {
throw new Error(`EXAMPLE env var is required. One of: ${Object.keys(EXAMPLES).join(', ')}`)
}
if (!isExampleName(name)) {
throw new Error(`Unknown example "${name}". One of: ${Object.keys(EXAMPLES).join(', ')}`)
}
return { name, config: EXAMPLES[name] }
}
40 changes: 40 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test'
import path from 'path'
import { resolveExample } from './examples'

// Playwright compiles this config as CommonJS, so `__dirname` (the e2e/ dir) is
// available; its parent is the repo root, from where example servers are run.
const repoRoot = path.resolve(__dirname, '..')

// A CI job runs one example at a time. `EXAMPLE` selects which one; Playwright
// builds and serves it, then tears the server down when the run finishes.
const { name, config } = resolveExample(process.env.EXAMPLE)
const baseURL = `http://localhost:${String(config.port)}`
const isCI = Boolean(process.env.CI)

export default defineConfig({
testDir: './tests',
// Leave enough headroom for the test's 60-second remote-service assertion.
timeout: 90_000,
fullyParallel: true,
forbidOnly: isCI,
// The JS agent talks to a remote service, so the first load can be flaky.
retries: isCI ? 2 : 0,
reporter: isCI ? [['github'], ['list'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL,
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: config.command,
cwd: repoRoot,
url: baseURL,
reuseExistingServer: !isCI,
// Production builds (esp. Next) can take a while on cold CI runners.
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
},
metadata: { example: name },
})
16 changes: 16 additions & 0 deletions e2e/tests/example.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test'

const VISITOR_ID = /^[A-Za-z0-9]{20}$/

test('identifies the visitor and renders a visitor ID', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))

await page.goto('/')

// Every example renders the visitor ID in:
// <span data-testid="visitor-id">…</span>
await expect(page.getByTestId('visitor-id')).toHaveText(VISITOR_ID, { timeout: 60_000 })

expect(errors, `uncaught errors on the page:\n${errors.join('\n')}`).toEqual([])
})
4 changes: 3 additions & 1 deletion examples/create-react-app/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
REACT_APP_FPJS_PUBLIC_API_KEY=<your-public-api-key>
REACT_APP_FPJS_PUBLIC_API_KEY=<your-public-api-key>
# Optional. One of: us (default), eu, ap. Leave empty for the default region.
REACT_APP_FPJS_REGION=
3 changes: 3 additions & 0 deletions examples/create-react-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
"react-scripts": "5.0.1"
},
"devDependencies": {
"sirv-cli": "^3.0.1",
"typescript": "catalog:"
},
"scripts": {
"dev": "PORT=3001 DISABLE_ESLINT_PLUGIN=true react-scripts start",
"start": "pnpm dev",
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build",
"serve": "sirv build --port 3001 --cors --single",
"typecheck": "tsc --noEmit",
"lint": "eslint . --max-warnings 0"
},
"browserslist": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { FingerprintProvider } from '@fingerprint/react'
import { Outlet } from 'react-router-dom'
import { FPJS_API_KEY } from '../shared/utils/env'
import { FPJS_API_KEY, FPJS_REGION } from '../shared/utils/env'
import { Nav } from '../shared/components/Nav'

function InMemoryCache() {
return (
<FingerprintProvider apiKey={FPJS_API_KEY} cache={{ storage: 'agent', duration: 'optimize-cost' }}>
<FingerprintProvider
apiKey={FPJS_API_KEY}
region={FPJS_REGION}
cache={{ storage: 'agent', duration: 'optimize-cost' }}
>
<div className='App'>
<header className='header'>
<h2>Solution with an in-memory cache</h2>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Outlet } from 'react-router-dom'
import { Nav } from '../shared/components/Nav'
import { FPJS_API_KEY } from '../shared/utils/env'
import { FPJS_API_KEY, FPJS_REGION } from '../shared/utils/env'
import { FingerprintProvider } from '@fingerprint/react'

function LocalStorageCache() {
return (
<FingerprintProvider
apiKey={FPJS_API_KEY}
region={FPJS_REGION}
cache={{
storage: 'localStorage',
duration: 60 * 10,
Expand Down
4 changes: 2 additions & 2 deletions examples/create-react-app/src/no_cache/WithoutCache.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { FingerprintProvider } from '@fingerprint/react'
import { Outlet } from 'react-router-dom'
import { Nav } from '../shared/components/Nav'
import { FPJS_API_KEY } from '../shared/utils/env'
import { FPJS_API_KEY, FPJS_REGION } from '../shared/utils/env'

function WithoutCache() {
return (
<FingerprintProvider apiKey={FPJS_API_KEY}>
<FingerprintProvider apiKey={FPJS_API_KEY} region={FPJS_REGION}>
<div className='App'>
<header className='header'>
<h2>Solution without cache</h2>
Expand Down
5 changes: 5 additions & 0 deletions examples/create-react-app/src/react-app-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// <reference types="react-scripts" />

// react-scripts' bundled types don't declare a plain (side-effect) CSS import
// under this repo's TypeScript version, so declare it explicitly.
declare module '*.css'
Loading
Loading