diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..e0335537 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/.gitignore b/.gitignore index 0326b51e..02277b60 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ # Coverage /coverage/ +# Playwright +/playwright-report/ +/test-results/ +/e2e/.cache/ + # misc .DS_Store .env.local diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..eb775125 --- /dev/null +++ b/e2e/README.md @@ -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= +export REACT_APP_FPJS_PUBLIC_API_KEY= +export NEXT_PUBLIC_FPJS_PUBLIC_API_KEY= +export PREACT_APP_FPJS_PUBLIC_API_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`. diff --git a/e2e/examples.ts b/e2e/examples.ts new file mode 100644 index 00000000..61d6464c --- /dev/null +++ b/e2e/examples.ts @@ -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/`) 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 + +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] } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..a4cfeeef --- /dev/null +++ b/e2e/playwright.config.ts @@ -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 }, +}) diff --git a/e2e/tests/example.spec.ts b/e2e/tests/example.spec.ts new file mode 100644 index 00000000..824f806d --- /dev/null +++ b/e2e/tests/example.spec.ts @@ -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: + // + await expect(page.getByTestId('visitor-id')).toHaveText(VISITOR_ID, { timeout: 60_000 }) + + expect(errors, `uncaught errors on the page:\n${errors.join('\n')}`).toEqual([]) +}) diff --git a/examples/create-react-app/.env.example b/examples/create-react-app/.env.example index dab2644c..54c8d4a8 100644 --- a/examples/create-react-app/.env.example +++ b/examples/create-react-app/.env.example @@ -1 +1,3 @@ -REACT_APP_FPJS_PUBLIC_API_KEY= \ No newline at end of file +REACT_APP_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +REACT_APP_FPJS_REGION= diff --git a/examples/create-react-app/package.json b/examples/create-react-app/package.json index a5a516e3..219c4d6f 100644 --- a/examples/create-react-app/package.json +++ b/examples/create-react-app/package.json @@ -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": { diff --git a/examples/create-react-app/src/in_memory_cache/InMemoryCache.tsx b/examples/create-react-app/src/in_memory_cache/InMemoryCache.tsx index 0e552a59..b16170a5 100644 --- a/examples/create-react-app/src/in_memory_cache/InMemoryCache.tsx +++ b/examples/create-react-app/src/in_memory_cache/InMemoryCache.tsx @@ -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 ( - +

Solution with an in-memory cache

diff --git a/examples/create-react-app/src/local_storage_cache/LocalStorageCache.tsx b/examples/create-react-app/src/local_storage_cache/LocalStorageCache.tsx index 0524e684..f06db439 100644 --- a/examples/create-react-app/src/local_storage_cache/LocalStorageCache.tsx +++ b/examples/create-react-app/src/local_storage_cache/LocalStorageCache.tsx @@ -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 ( +

Solution without cache

diff --git a/examples/create-react-app/src/react-app-env.d.ts b/examples/create-react-app/src/react-app-env.d.ts new file mode 100644 index 00000000..4ac57bf1 --- /dev/null +++ b/examples/create-react-app/src/react-app-env.d.ts @@ -0,0 +1,5 @@ +/// + +// 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' diff --git a/examples/create-react-app/src/session_storage_cache/SessionStorageCache.tsx b/examples/create-react-app/src/session_storage_cache/SessionStorageCache.tsx index f76addca..540fb0be 100644 --- a/examples/create-react-app/src/session_storage_cache/SessionStorageCache.tsx +++ b/examples/create-react-app/src/session_storage_cache/SessionStorageCache.tsx @@ -1,11 +1,15 @@ 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 SessionStorageCache() { return ( - +

Solution with a custom implementation of a session storage cache

diff --git a/examples/create-react-app/src/shared/components/VisitorDataPresenter.tsx b/examples/create-react-app/src/shared/components/VisitorDataPresenter.tsx index 7d808835..0ef7f4bc 100644 --- a/examples/create-react-app/src/shared/components/VisitorDataPresenter.tsx +++ b/examples/create-react-app/src/shared/components/VisitorDataPresenter.tsx @@ -17,9 +17,11 @@ function VisitorDataPresenter({

Visitor ID:{' '} - {isLoading === true ? 'Loading...' : data !== undefined ? data.visitor_id : 'not established yet'} + + {isLoading === true ? 'Loading...' : (data?.visitor_id ?? 'not established yet')} +

- {data && ( + {data !== undefined && ( <>

Full visitor data: diff --git a/examples/create-react-app/src/shared/utils/env.ts b/examples/create-react-app/src/shared/utils/env.ts index 39f62452..468b53eb 100644 --- a/examples/create-react-app/src/shared/utils/env.ts +++ b/examples/create-react-app/src/shared/utils/env.ts @@ -1 +1,5 @@ export const FPJS_API_KEY = process.env.REACT_APP_FPJS_PUBLIC_API_KEY ?? 'test_public_key' + +// Optional. Defaults to the SDK's default region (us) when unset or invalid. +const rawRegion = process.env.REACT_APP_FPJS_REGION +export const FPJS_REGION = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined diff --git a/examples/create-react-app/tsconfig.json b/examples/create-react-app/tsconfig.json index b1c7462d..dc3a1ec6 100644 --- a/examples/create-react-app/tsconfig.json +++ b/examples/create-react-app/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2015", "lib": [ "dom", "dom.iterable", @@ -14,7 +14,7 @@ "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, diff --git a/examples/next-appDir/.env.example b/examples/next-appDir/.env.example index a7dd9114..e8c721be 100644 --- a/examples/next-appDir/.env.example +++ b/examples/next-appDir/.env.example @@ -1 +1,3 @@ -NEXT_PUBLIC_FPJS_PUBLIC_API_KEY= \ No newline at end of file +NEXT_PUBLIC_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +NEXT_PUBLIC_FPJS_REGION= diff --git a/examples/next-appDir/app/HomePage.tsx b/examples/next-appDir/app/HomePage.tsx index 5d0418fb..fba93e70 100644 --- a/examples/next-appDir/app/HomePage.tsx +++ b/examples/next-appDir/app/HomePage.tsx @@ -27,7 +27,10 @@ const HomePage = () => {

- VisitorId: {isLoading ? 'Loading...' : data?.visitor_id} + Visitor ID:{' '} + + {isLoading ? 'Loading...' : data?.visitor_id} +

Full visitor data:

{error ? error.message : JSON.stringify(data, null, 2)}
diff --git a/examples/next-appDir/app/layout.tsx b/examples/next-appDir/app/layout.tsx index 53c035e1..b5825f6e 100644 --- a/examples/next-appDir/app/layout.tsx +++ b/examples/next-appDir/app/layout.tsx @@ -13,11 +13,17 @@ function getPublicApiKey(): string { const fpjsPublicApiKey = getPublicApiKey() +// Optional. Defaults to the SDK's default region (us) when unset or invalid. +const rawRegion = process.env.NEXT_PUBLIC_FPJS_REGION +const fpjsRegion = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined + function RootLayout({ children }: PropsWithChildren) { return ( - {children} + + {children} + ) diff --git a/examples/next-appDir/package.json b/examples/next-appDir/package.json index 771ff4e3..1f8b1916 100644 --- a/examples/next-appDir/package.json +++ b/examples/next-appDir/package.json @@ -8,7 +8,8 @@ "scripts": { "dev": "next dev --port=3003", "build": "next build", - "start": "next start", + "start": "next start --port 3003", + "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings 0" }, "dependencies": { diff --git a/examples/next/.env.example b/examples/next/.env.example index a7dd9114..e8c721be 100644 --- a/examples/next/.env.example +++ b/examples/next/.env.example @@ -1 +1,3 @@ -NEXT_PUBLIC_FPJS_PUBLIC_API_KEY= \ No newline at end of file +NEXT_PUBLIC_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +NEXT_PUBLIC_FPJS_REGION= diff --git a/examples/next/package.json b/examples/next/package.json index b41bf330..099e4292 100644 --- a/examples/next/package.json +++ b/examples/next/package.json @@ -8,7 +8,8 @@ "scripts": { "dev": "next dev --port=3002", "build": "next build", - "start": "next start", + "start": "next start --port 3002", + "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings 0" }, "dependencies": { diff --git a/examples/next/pages/_app.tsx b/examples/next/pages/_app.tsx index 42fade55..ca71d246 100644 --- a/examples/next/pages/_app.tsx +++ b/examples/next/pages/_app.tsx @@ -13,9 +13,13 @@ function getPublicApiKey(): string { const fpjsPublicApiKey = getPublicApiKey() +// Optional. Defaults to the SDK's default region (us) when unset or invalid. +const rawRegion = process.env.NEXT_PUBLIC_FPJS_REGION +const fpjsRegion = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined + function MyApp({ Component, pageProps }: AppProps) { return ( - + ) diff --git a/examples/next/pages/index.tsx b/examples/next/pages/index.tsx index e4b0a3dc..c28fa190 100644 --- a/examples/next/pages/index.tsx +++ b/examples/next/pages/index.tsx @@ -33,7 +33,10 @@ const Home: NextPage = () => {

- VisitorId: {isLoading ? 'Loading...' : data?.visitor_id} + Visitor ID:{' '} + + {isLoading ? 'Loading...' : data?.visitor_id} +

Full visitor data:

{error ? error.message : JSON.stringify(data, null, 2)}
diff --git a/examples/preact/.env.example b/examples/preact/.env.example index 8961791d..e5f8a44b 100644 --- a/examples/preact/.env.example +++ b/examples/preact/.env.example @@ -1 +1,3 @@ -PREACT_APP_FPJS_PUBLIC_API_KEY= \ No newline at end of file +PREACT_APP_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +PREACT_APP_FPJS_REGION= diff --git a/examples/preact/package.json b/examples/preact/package.json index 3866d6bb..45057057 100644 --- a/examples/preact/package.json +++ b/examples/preact/package.json @@ -4,9 +4,10 @@ "version": "0.0.0", "license": "MIT", "scripts": { - "build": "preact build --no-sw", + "build": "pnpm typecheck && preact build --no-sw", "serve": "sirv build --port 8080 --cors --single", "dev": "preact watch --no-sw", + "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings 0" }, "dependencies": { @@ -15,7 +16,6 @@ "preact-render-to-string": "^6.7.0" }, "devDependencies": { - "dotenv": "^17.4.2", "preact-cli": "^3.5.1", "sirv-cli": "^3.0.1", "typescript": "catalog:" diff --git a/examples/preact/preact.config.js b/examples/preact/preact.config.js deleted file mode 100644 index a92bd82a..00000000 --- a/examples/preact/preact.config.js +++ /dev/null @@ -1,10 +0,0 @@ -import dotenv from 'dotenv' - -dotenv.config() - -export default (config, env, helpers) => { - const { plugin } = helpers.getPluginsByName(config, 'DefinePlugin')[0] - plugin.definitions['process.env.PREACT_APP_FPJS_PUBLIC_API_KEY'] = JSON.stringify( - process.env.PREACT_APP_FPJS_PUBLIC_API_KEY - ) -} diff --git a/examples/preact/src/components/app.tsx b/examples/preact/src/components/app.tsx index 3e22fa80..07a2c27d 100644 --- a/examples/preact/src/components/app.tsx +++ b/examples/preact/src/components/app.tsx @@ -25,7 +25,10 @@ const App: FunctionalComponent = () => {

- VisitorId: {isLoading ? 'Loading...' : data?.visitor_id} + Visitor ID:{' '} + + {isLoading ? 'Loading...' : data?.visitor_id} +

Full visitor data:

{error ? error.message : JSON.stringify(data, null, 2)}
diff --git a/examples/preact/src/env.d.ts b/examples/preact/src/env.d.ts new file mode 100644 index 00000000..9f6a0128 --- /dev/null +++ b/examples/preact/src/env.d.ts @@ -0,0 +1,9 @@ +// Preact CLI inlines PREACT_APP_* environment variables at build time. +// Declare just the ones this example reads so TypeScript knows about `process` +// without pulling in all of @types/node into a browser app. +declare const process: { + env: { + PREACT_APP_FPJS_PUBLIC_API_KEY?: string + PREACT_APP_FPJS_REGION?: string + } +} diff --git a/examples/preact/src/index.tsx b/examples/preact/src/index.tsx index c01937a2..6c90e5c7 100644 --- a/examples/preact/src/index.tsx +++ b/examples/preact/src/index.tsx @@ -9,8 +9,12 @@ const WrappedApp: FunctionalComponent = () => { throw new Error('PREACT_APP_FPJS_PUBLIC_API_KEY is not set') } + // Optional. Defaults to the SDK's default region (us) when unset or invalid. + const rawRegion = process.env.PREACT_APP_FPJS_REGION + const region = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined + return ( - + ) diff --git a/examples/preact/tsconfig.json b/examples/preact/tsconfig.json index 81b4e3a7..fcfc0c39 100644 --- a/examples/preact/tsconfig.json +++ b/examples/preact/tsconfig.json @@ -5,7 +5,7 @@ "react-dom": ["./node_modules/preact/compat/"] }, /* Basic Options */ - "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ + "target": "ES2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ "module": "ESNext", /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation: */ "allowJs": true, /* Allow javascript files to be compiled. */ @@ -39,7 +39,7 @@ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ /* Module Resolution Options */ - "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + "moduleResolution": "bundler", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ "esModuleInterop": true, /* */ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ diff --git a/examples/vite/.env.example b/examples/vite/.env.example index acd0b4e5..42b9c658 100644 --- a/examples/vite/.env.example +++ b/examples/vite/.env.example @@ -1 +1,3 @@ VITE_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +VITE_FPJS_REGION= diff --git a/examples/vite/package.json b/examples/vite/package.json index 6c68757c..ba33331f 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview", + "preview": "vite preview --port 5173 --strictPort", + "typecheck": "tsc -b --pretty false", "lint": "eslint . --max-warnings 0" }, "dependencies": { diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 5b9b35e6..026254dd 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,7 +1,7 @@ import { useVisitorData } from '@fingerprint/react' function App() { - const { isLoading, error, isFetched, data } = useVisitorData() + const { isLoading, error, data } = useVisitorData() if (isLoading) { return
Loading...
@@ -9,11 +9,15 @@ function App() { if (error) { return
An error occurred: {error.message}
} - - if (isFetched) { - return
Welcome {data.visitor_id}!
+ if (data?.visitor_id === undefined || data.visitor_id === '') { + return null } - return null + + return ( +
+ Visitor ID: {data.visitor_id} +
+ ) } export default App diff --git a/examples/vite/src/main.tsx b/examples/vite/src/main.tsx index fae354a3..48e30098 100644 --- a/examples/vite/src/main.tsx +++ b/examples/vite/src/main.tsx @@ -9,6 +9,10 @@ if (apiKey === undefined || apiKey === '') { throw new Error('VITE_FPJS_PUBLIC_API_KEY is not set') } +// Optional. Defaults to the SDK's default region (us) when unset or invalid. +const rawRegion = import.meta.env.VITE_FPJS_REGION +const region = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined + const rootElement = document.getElementById('root') if (rootElement === null) { throw new Error('Root element not found') @@ -16,7 +20,7 @@ if (rootElement === null) { createRoot(rootElement).render( - + diff --git a/examples/vite/src/vite-env.d.ts b/examples/vite/src/vite-env.d.ts index af5f53d7..f8127547 100644 --- a/examples/vite/src/vite-env.d.ts +++ b/examples/vite/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_FPJS_PUBLIC_API_KEY?: string + readonly VITE_FPJS_REGION?: string } interface ImportMeta { diff --git a/examples/webpack-based/.env.example b/examples/webpack-based/.env.example index 994ffaea..39e24857 100644 --- a/examples/webpack-based/.env.example +++ b/examples/webpack-based/.env.example @@ -1 +1,3 @@ REACT_APP_FPJS_PUBLIC_API_KEY= +# Optional. One of: us (default), eu, ap. Leave empty for the default region. +REACT_APP_FPJS_REGION= diff --git a/examples/webpack-based/package.json b/examples/webpack-based/package.json index 47845dc9..b5cc3aa7 100644 --- a/examples/webpack-based/package.json +++ b/examples/webpack-based/package.json @@ -6,7 +6,8 @@ "license": "MIT", "scripts": { "dev": "webpack-dev-server", - "build": "webpack", + "build": "NODE_ENV=production webpack --mode production", + "serve": "sirv dist --port 8081 --cors --single", "lint": "eslint . --max-warnings 0" }, "devDependencies": { @@ -16,6 +17,7 @@ "babel-loader": "^10.1.1", "dotenv-webpack": "^9.0.0", "html-webpack-plugin": "^5.6.7", + "sirv-cli": "^3.0.1", "webpack": "^5.108.4", "webpack-cli": "^7.2.1", "webpack-dev-server": "^6.0.0" diff --git a/examples/webpack-based/src/App.js b/examples/webpack-based/src/App.js index b437892f..026254dd 100644 --- a/examples/webpack-based/src/App.js +++ b/examples/webpack-based/src/App.js @@ -9,11 +9,15 @@ function App() { if (error) { return
An error occurred: {error.message}
} - - if (data) { - return
Welcome {data.visitor_id}!
+ if (data?.visitor_id === undefined || data.visitor_id === '') { + return null } - return null + + return ( +
+ Visitor ID: {data.visitor_id} +
+ ) } export default App diff --git a/examples/webpack-based/src/index.js b/examples/webpack-based/src/index.js index 3ba95769..f4283e43 100644 --- a/examples/webpack-based/src/index.js +++ b/examples/webpack-based/src/index.js @@ -6,10 +6,13 @@ import App from './App' const rootElement = document.getElementById('root') const root = createRoot(rootElement) const apiKey = process.env.REACT_APP_FPJS_PUBLIC_API_KEY +// Optional. Defaults to the SDK's default region (us) when unset or invalid. +const rawRegion = process.env.REACT_APP_FPJS_REGION +const region = rawRegion === 'us' || rawRegion === 'eu' || rawRegion === 'ap' ? rawRegion : undefined root.render( - + diff --git a/examples/webpack-based/webpack.config.js b/examples/webpack-based/webpack.config.js index ca3a6dc6..38939c2d 100644 --- a/examples/webpack-based/webpack.config.js +++ b/examples/webpack-based/webpack.config.js @@ -27,6 +27,10 @@ module.exports = { plugins: [ new Dotenv({ path: './.env.local', + // Fall back to process.env (e.g. CI-provided vars) and stay quiet when + // the local file is absent, so the example builds without a .env.local. + systemvars: true, + silent: true, }), new HtmlWebpackPlugin({ template: './src/index.html', // base html diff --git a/package.json b/package.json index 95f123d5..24d457c2 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:coverage": "vitest run --coverage", "test:coverage:diff": "vitest run --coverage --reporter json --outputFile.json=report.json", "test:dts": "tsc --noEmit --isolatedModules --ignoreConfig dist/fingerprint-react.d.ts", + "test:e2e": "playwright test --config e2e/playwright.config.ts", "docs": "typedoc src/index.ts --out docs", "changeset:version": "changeset version", "changeset:publish": "HUSKY=0 changeset publish" @@ -75,6 +76,7 @@ "@fingerprintjs/tsconfig-dx-team": "^0.0.2", "@microsoft/api-extractor": "^7.58.9", "@next/eslint-plugin-next": "^16.2.10", + "@playwright/test": "^1.61.1", "@testing-library/preact": "^3.2.4", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04769a12..90efae9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@next/eslint-plugin-next': specifier: ^16.2.10 version: 16.2.10 + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@testing-library/preact': specifier: ^3.2.4 version: 3.2.4(preact@10.29.7) @@ -160,6 +163,9 @@ importers: specifier: 5.0.1 version: 5.0.1(@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7))(@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7))(@types/babel__core@7.20.5)(@types/webpack@4.41.40)(esbuild@0.28.1)(eslint@10.7.0(jiti@1.21.7))(react@18.3.1)(type-fest@0.21.3)(typescript@6.0.3)(yaml@2.9.0) devDependencies: + sirv-cli: + specifier: ^3.0.1 + version: 3.0.1 typescript: specifier: 'catalog:' version: 6.0.3 @@ -171,7 +177,7 @@ importers: version: link:../.. next: specifier: 16.2.10 - version: 16.2.10(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/node': specifier: 'catalog:' @@ -199,7 +205,7 @@ importers: version: link:../.. next: specifier: 16.2.10 - version: 16.2.10(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/node': specifier: 'catalog:' @@ -232,9 +238,6 @@ importers: specifier: ^6.7.0 version: 6.7.0(preact@10.29.7) devDependencies: - dotenv: - specifier: ^17.4.2 - version: 17.4.2 preact-cli: specifier: ^3.5.1 version: 3.5.1(@types/babel__core@7.20.5)(bluebird@3.7.2)(eslint@10.7.0(jiti@2.6.1))(preact-render-to-string@6.7.0)(preact@10.29.7)(typescript@6.0.3) @@ -306,6 +309,9 @@ importers: html-webpack-plugin: specifier: ^5.6.7 version: 5.6.7(webpack@5.108.4) + sirv-cli: + specifier: ^3.0.1 + version: 3.0.1 webpack: specifier: ^5.108.4 version: 5.108.4(esbuild@0.28.1)(webpack-cli@7.2.1) @@ -2648,6 +2654,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@pmmmwh/react-refresh-webpack-plugin@0.5.17': resolution: {integrity: sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==} engines: {node: '>= 10.13'} @@ -5203,10 +5214,6 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -5946,6 +5953,11 @@ packages: os: [darwin] deprecated: Upgrade to fsevents v2 to mitigate potential security issues + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -8088,6 +8100,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pn@1.1.0: resolution: {integrity: sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==} @@ -13792,6 +13814,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@pmmmwh/react-refresh-webpack-plugin@0.5.17(@types/webpack@4.41.40)(react-refresh@0.11.0)(type-fest@0.21.3)(webpack-dev-server@4.15.2(webpack@5.108.4(esbuild@0.28.1)(postcss@8.5.19)))(webpack@5.108.4(esbuild@0.28.1)(postcss@8.5.19))': dependencies: ansi-html: 0.0.9 @@ -16704,8 +16730,6 @@ snapshots: dotenv@16.6.1: {} - dotenv@17.4.2: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -17834,6 +17858,9 @@ snapshots: nan: 2.28.0 optional: true + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -19840,7 +19867,7 @@ snapshots: neo-async@2.6.2: {} - next@16.2.10(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 16.2.10 '@swc/helpers': 0.5.15 @@ -19859,6 +19886,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.10 '@next/swc-win32-arm64-msvc': 16.2.10 '@next/swc-win32-x64-msvc': 16.2.10 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -20304,6 +20332,14 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pn@1.1.0: {} pnp-webpack-plugin@1.7.0(typescript@6.0.3): diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index b9b4aebe..851a49cd 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -9,6 +9,7 @@ "__tests__", "vite.config.ts", "vitest.config.ts", + "e2e/**/*.ts", "examples/**/*.ts", "examples/**/*.tsx", "examples/vite/src/vite-env.d.ts"