diff --git a/.github/workflows/pr_web.yml b/.github/workflows/pr_web.yml index 74410871..450a007b 100644 --- a/.github/workflows/pr_web.yml +++ b/.github/workflows/pr_web.yml @@ -38,3 +38,40 @@ jobs: - name: Test run: npm test + + e2e: + name: E2E tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Install dependencies + run: npm ci --workspaces --include-workspace-root + + - name: Build workspaces + run: npm run build + + - name: Install & update e2e app with latest react-native-theoplayer + working-directory: apps/e2e + run: npm install react-native-theoplayer@latest + + - name: Run e2e tests + working-directory: apps/e2e + env: + E2E_HEADLESS: 'true' + run: npm run test:e2e:web -- --markdown + + - name: Summarize results + working-directory: apps/e2e + if: always() + run: | + if [ -f cavynext_results.md ]; then + cat cavynext_results.md >> $GITHUB_STEP_SUMMARY + fi diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..86faa5cf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,133 @@ +# AGENTS.md + +Guidance for coding agents working in this repository. Keep it short and factual; if something here +turns out to be wrong, fix this file in the same PR. + +## What this repo is + +An npm-workspaces monorepo of connectors for +[`react-native-theoplayer`](https://github.com/THEOplayer/react-native-theoplayer): analytics +integrations (`adobe`, `adobe-edge`, `adscript`, `agama`, `bitmovin`, `comscore`, `conviva`, +`gemius`, `mux`, `nielsen`, `npaw`, `youbora`), plus `drm`, `engage` and `yospace`. Each top-level +directory is a published package; `apps/e2e` is the React Native test app and `apps/engage-example` +a sample app. + +Each connector typically has: + +- `src/api` — public API re-exported from `src/index.ts` (this is what typedoc documents). +- `src/internal` — implementation, split per platform: a shared `*Adapter.ts` interface with + `*AdapterNative.ts` (Android/iOS bridge) and `*AdapterWeb.ts` (browser SDK) implementations, the + latter usually under `src/internal/web`. +- `android/` (Kotlin/Java), `ios/` + `*.podspec` (Swift/Obj-C) for native connectors. +- Build via `react-native-builder-bob` (`npm run build` → `lib/`); `npm run prepare` also + regenerates `src/manifest.json`. + +Node 22 is used in CI. + +## Commands + +Run from the repo root unless stated otherwise. + +| Task | Command | Notes | +| --- | --- | --- | +| Install | `npm ci --workspaces --include-workspace-root` | | +| Build all packages | `npm run build` | bob build per workspace | +| Docs check ("root tests") | `npm test` | alias for `npm run test:docs` → typedoc with `--treatWarningsAsErrors`; **not** a unit-test run | +| Lint | `npm run lint` / `npm run lint:fix` | eslint over `*/{src,test}` | +| Format | `npm run prettier` / `npm run prettier:fix` | prettier `--check`; single quotes, trailing commas, print width 150 | +| Unit tests (per package) | `cd adobe-edge && npm test` | jest + ts-jest; see below | +| Typecheck (per package) | `cd adobe-edge && npm run typescript` | `tsc --noEmit` | +| E2E app | `cd apps/e2e && npm run test:e2e:android` / `test:e2e:ios` / `test:e2e:tvos` | CavyNext, needs an emulator/simulator | +| E2E app (web) | `cd apps/e2e && npm run test:e2e:web` | webpack dev server + CavyNext; opens your default browser. Set `E2E_HEADLESS=true` for headless Chrome (what CI does) | + +Gotchas worth knowing before you claim "tests pass": + +- **The root `npm test` is a documentation check, not a test suite.** It fails on typedoc warnings + (e.g. an exported symbol that is not documented or not reachable from `src/index.ts`). +- **Unit tests exist in only some packages.** At the time of writing, `adobe-edge` is the only + workspace with a `test` script (`jest.config.js` + `src/**/__tests__`). Adding tests to other + connectors is welcome; copy the `adobe-edge` setup. +- **`npm run prettier` at the root is currently red on pre-existing files** under `comscore/`, + `drm/` and `engage/`. Do not reformat those as a side effect of an unrelated change; only ensure + the files you touched are formatted (the commit hook does this for staged files via + husky + lint-staged). +- `npm run lint` currently reports warnings (e.g. `@typescript-eslint/no-explicit-any`, + unused vars). Errors must be zero; do not mass-fix unrelated warnings in a feature PR. + +## What CI actually verifies + +- `pr_web.yml`: install, `npm run build`, `npm test` (docs check), plus the CavyNext e2e suite in + headless Chrome (`E2E_HEADLESS=true npm run test:e2e:web`). No jest, no tsc per package, no lint, + no prettier. +- `pr_android.yml` / `pr_ios.yml`: install, build, docs check, then build the `apps/e2e` app against + `react-native-theoplayer@latest` and run the CavyNext e2e suite on an emulator/simulator + (Android API 36; iOS + tvOS on Xcode 26.2). These are skipped for draft PRs. +- `release.yml` (on `main`): changesets action opens/updates a "Release" PR or publishes to npm. + +Consequence for agents: green CI does **not** mean the package's unit tests or typecheck passed. Run +`npm test` and `npm run typescript` inside the package you changed, locally, and say so in the PR. + +## Conventions + +- **Changesets are required** for any user-facing change: add a markdown file under `.changeset/` + naming the affected package(s) and a semver bump, e.g. + + ```md + --- + '@theoplayer/react-native-analytics-adobe-edge': patch + --- + + Fixed , which happened because . + ``` + + No changeset means the fix ships without a version bump or changelog entry. Internal-only changes + (tests, CI, docs) don't need one. +- **Player/SDK version support** is expressed through peerDependencies, typically + `react-native-theoplayer: ^10 || ^11` and `theoplayer: ^10 || ^11` (THEOplayer 10/11 are branded + OptiView). Don't tighten or widen these ranges casually — it is a breaking change for consumers. +- **Public API changes must stay documented**: everything exported from `src/index.ts` is fed to + typedoc with warnings-as-errors, so add TSDoc for new public symbols or the docs check fails. +- Keep platform-specific code behind the adapter split rather than branching on `Platform.OS` in + shared code. +- Commit hooks (`.husky/pre-commit` → `lint-staged`) run prettier on staged files. Don't bypass them + with `--no-verify`. + +## E2E app on web + +`apps/e2e` runs on react-native-web through webpack (`web/webpack.config.js`). Things that are easy +to get wrong: + +- CavyNext only accepts entry files ending in `.js`, and swaps `index.web.js` for + `index.test.web.js` while running (restoring it on exit). Keep both as thin wrappers around + `web/app.web.tsx` / `web/test.web.tsx`; if a run is interrupted you may find a leftover + `index.notest.js` that has to be renamed back. +- THEOplayer worker/iframe assets are copied to `libraryLocation` from the `theoplayer` package + resolved **from `apps/e2e`**, not from the workspace root: the app can hoist a different version, + and mismatched workers fail at runtime with "THEOplayer workers could not be loaded". +- The web player needs `mutedAutoplay` and a `libraryLocation` to autoplay in a headless browser; + see `src/TestableApp.tsx`. +- Headless runs use a dedicated Chrome profile so they don't join an already running browser — + a second window in an existing instance shows up as "an extra app connection" and the run hangs. + +## Unit tests (adobe-edge, as the reference setup) + +`adobe-edge/src/internal/web/__tests__` shows the intended pattern for web-connector tests: + +- `mocks/alloy.ts` mocks `@adobe/alloy`'s `createInstance`/media tracker; `mocks/player.ts` provides + a minimal event-emitting player stub, so tests drive behaviour by emitting player events + (`loadedmetadata`, `sourcechange`, …). +- Async edge SDK behaviour is driven with explicit deferreds plus a `flushMicrotasks()` helper + (a `MessageChannel` round-trip) and `jest.useFakeTimers()` / + `jest.advanceTimersByTimeAsync()` for retry/backoff logic. +- Mock implementations returning heterogeneous promises usually need an explicit `: any` return + annotation to satisfy `tsc --noEmit`. + +Third-party SDK behaviour is best verified against the pinned dependency in `node_modules` (e.g. +`node_modules/@adobe/alloy/dist/alloy.js`) rather than from memory — vendor docs and the shipped +implementation do diverge. + +## PRs + +- Reference the customer-visible symptom, not just the code change, and include the changeset. +- State which package-level checks you ran (`npm test`, `npm run typescript`) since CI won't. +- Don't include unrelated formatting churn. diff --git a/apps/e2e/index.test.web.js b/apps/e2e/index.test.web.js new file mode 100644 index 00000000..6cde4315 --- /dev/null +++ b/apps/e2e/index.test.web.js @@ -0,0 +1,3 @@ +// Web entry point used by `npm run test:e2e:web`; cavynext temporarily swaps it +// in for `index.web.js`. See index.web.js. +import './web/test.web'; diff --git a/apps/e2e/index.web.js b/apps/e2e/index.web.js new file mode 100644 index 00000000..82860cd0 --- /dev/null +++ b/apps/e2e/index.web.js @@ -0,0 +1,6 @@ +// Web entry point of the app. +// +// This file is intentionally a thin, JSX-free wrapper: cavynext swaps +// `index.web.js` and `index.test.web.js` to boot the app into its test runner, +// and it only accepts entry files ending in `.js`. +import './web/app.web'; diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 3dcc7300..2d4858ef 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -8,6 +8,7 @@ "test:e2e:android": "npx cavynext run-android --terminal bash --mode release", "test:e2e:ios": "npx cavynext run-ios --terminal bash --scheme ReactNativeTHEOplayer", "test:e2e:tvos": "npx cavynext run-ios --terminal bash --scheme ReactNativeTHEOplayer-tvOS", + "test:e2e:web": "npx cavynext run-web --boot-timeout 10 --buildCmd \"npx webpack serve --mode development --config web/webpack.config.js\"", "android": "npx react-native run-android", "android-release": "npx react-native run-android --mode release", "ios": "npx react-native run-ios", diff --git a/apps/e2e/src/TestableApp.tsx b/apps/e2e/src/TestableApp.tsx index 6ef3e25b..f7753697 100644 --- a/apps/e2e/src/TestableApp.tsx +++ b/apps/e2e/src/TestableApp.tsx @@ -4,6 +4,18 @@ import { Platform, SafeAreaView, StyleSheet, View, ViewStyle } from 'react-nativ import { TestableTHEOplayerView } from './components/TestableTHEOplayerView'; import Specs from './tests'; import { getStatusBarHeight } from 'react-native-status-bar-height'; +import type { PlayerConfiguration } from 'react-native-theoplayer'; +import Config from 'react-native-config'; + +// On web, the transmuxer worker files are served from `libraryLocation` and +// autoplay is only allowed while muted; both are needed to play the test +// sources. The license is optional: the test sources are demo sources hosted on +// a '*.theoplayer.com' domain, which play without one. +const PLAYER_CONFIG: PlayerConfiguration = { + license: Config.THEO_LICENSE_KEY, + libraryLocation: 'theoplayer', + mutedAutoplay: 'all', +}; const testHookStore = new TestHookStore(); // Debug simulators need extra time for cold-start native player creation. @@ -30,7 +42,7 @@ export class TestableApp extends Component { - + diff --git a/apps/e2e/index.web.tsx b/apps/e2e/web/app.web.tsx similarity index 87% rename from apps/e2e/index.web.tsx rename to apps/e2e/web/app.web.tsx index c0191c41..f836d0ca 100644 --- a/apps/e2e/index.web.tsx +++ b/apps/e2e/web/app.web.tsx @@ -1,6 +1,6 @@ import React, { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import App from './src/App'; +import App from '../src/App'; createRoot(document.getElementById('app') as HTMLElement).render( diff --git a/apps/e2e/web/test.web.tsx b/apps/e2e/web/test.web.tsx new file mode 100644 index 00000000..648668f4 --- /dev/null +++ b/apps/e2e/web/test.web.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { TestableApp } from '../src/TestableApp'; + +// Deliberately not wrapped in , for the reasons documented on TestableApp. +createRoot(document.getElementById('app') as HTMLElement).render(); diff --git a/apps/e2e/web/webpack.config.js b/apps/e2e/web/webpack.config.js index 7de45dfd..d7e2c331 100644 --- a/apps/e2e/web/webpack.config.js +++ b/apps/e2e/web/webpack.config.js @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-var-requires,no-undef */ +const os = require('os'); const path = require('path'); const webpack = require('webpack'); const dotenv = require('dotenv'); @@ -9,6 +10,10 @@ const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); const workspaceDirectory = path.resolve(__dirname, '../../..'); const appDirectory = path.resolve(__dirname, '..'); +// Resolve theoplayer from the app itself: the app can hoist a different version +// than the workspace root, and its worker files must match the bundled player. +const theoplayerDirectory = path.dirname(require.resolve('theoplayer/package.json', { paths: [appDirectory] })); + // A folder for any stub components we need in case there is no counterpart for it on react-native-web. const stubDirectory = path.resolve(appDirectory, './web/stub/'); @@ -24,11 +29,12 @@ const libraryLocation = 'theoplayer'; // Webpack's output location const outputLocation = 'dist'; -// Prepare env keys +// Prepare env keys. The .env file is optional: connector credentials are only +// needed to talk to real back-ends, not to build or to run the e2e suite. const envPath = path.resolve(appDirectory, '.env'); -const env = dotenv.parse(fs.readFileSync(envPath)); +const env = fs.existsSync(envPath) ? dotenv.parse(fs.readFileSync(envPath)) : {}; const envKeys = { - 'GLOBAL_ENV': `{${Object.entries(env) + GLOBAL_ENV: `{${Object.entries(env) .map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)}`) .join(',')}}`, }; @@ -38,13 +44,18 @@ const CopyWebpackPluginConfig = new CopyWebpackPlugin({ { // Copy transmuxer worker files. // THEOplayer will find them by setting `libraryLocation` in the playerConfiguration. - from: path.resolve(workspaceDirectory, './node_modules/theoplayer/THEOplayer.transmux.*').replace(/\\/g, '/'), + from: path.resolve(theoplayerDirectory, './THEOplayer.transmux.*').replace(/\\/g, '/'), to: `${libraryLocation}/[name][ext]`, }, { // Copy service worker // THEOplayer will find them by setting `libraryLocation` in the playerConfiguration. - from: path.resolve(workspaceDirectory, './node_modules/theoplayer/theoplayer.sw.js').replace(/\\/g, '/'), + from: path.resolve(theoplayerDirectory, './theoplayer.sw.js').replace(/\\/g, '/'), + to: `${libraryLocation}/[name][ext]`, + }, + { + // Copy the iframe helper page, loaded from `libraryLocation`. + from: path.resolve(theoplayerDirectory, './iframe.html').replace(/\\/g, '/'), to: `${libraryLocation}/[name][ext]`, }, { @@ -91,7 +102,7 @@ module.exports = { // load any web API polyfills // path.resolve(appDirectory, 'polyfills-web.js'), // your web-specific entry file - path.resolve(appDirectory, 'index.web.tsx'), + path.resolve(appDirectory, 'index.web.js'), ], // configures where the build ends up @@ -122,7 +133,27 @@ module.exports = { plugins: [HTMLWebpackPluginConfig, CopyWebpackPluginConfig, new NodePolyfillPlugin(), new webpack.DefinePlugin(envKeys)], devServer: { // Tells dev-server to open the browser after server had been started. - open: true, + // With E2E_HEADLESS=true (CI) a headless Chrome is opened instead of the + // default browser, so the cavynext suite can run without a desktop. + open: + process.env.E2E_HEADLESS === 'true' + ? { + app: { + name: process.env.E2E_BROWSER || 'google-chrome', + arguments: [ + '--headless=new', + '--no-sandbox', + '--disable-dev-shm-usage', + '--autoplay-policy=no-user-gesture-required', + '--mute-audio', + // A dedicated profile, so this run neither joins nor disturbs an + // already running browser: a second window in an existing + // instance would report to cavynext as a duplicate app. + `--user-data-dir=${path.join(os.tmpdir(), 'theoplayer-e2e-web-profile')}`, + ], + }, + } + : true, historyApiFallback: true, static: [ {