diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 36acd7584..5d1aa0467 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -25,6 +25,7 @@ jobs: e2e_node_sdk_web_sdk: ${{ steps.filter.outputs.e2e_node_sdk_web_sdk }} e2e_web_sdk: ${{ steps.filter.outputs.e2e_web_sdk }} e2e_web_sdk_react: ${{ steps.filter.outputs.e2e_web_sdk_react }} + e2e_web_sdk_angular: ${{ steps.filter.outputs.e2e_web_sdk_angular }} e2e_react_web_sdk: ${{ steps.filter.outputs.e2e_react_web_sdk }} e2e_react_native_android: ${{ steps.filter.outputs.e2e_react_native_android }} e2e_android: ${{ steps.filter.outputs.e2e_android }} @@ -74,6 +75,11 @@ jobs: - '{implementations/web-sdk_react/**,lib/**,packages/web/web-sdk/**,packages/web/preview-panel/**,packages/universal/core-sdk/**,packages/universal/api-client/**,packages/universal/api-schemas/**,package.json,pnpm-lock.yaml,.github/workflows/main-pipeline.yaml}' - '!**/*.@(md|mdx|markdown)' - '!{docs/**,documentation/**,**/docs/**,**/documentation/**}' + # Angular + Web SDK implementation E2E coverage scope. + e2e_web_sdk_angular: + - '{implementations/web-sdk_angular/**,lib/**,packages/web/web-sdk/**,packages/web/preview-panel/**,packages/universal/core-sdk/**,packages/universal/api-client/**,packages/universal/api-schemas/**,package.json,pnpm-lock.yaml,.github/workflows/main-pipeline.yaml}' + - '!**/*.@(md|mdx|markdown)' + - '!{docs/**,documentation/**,**/docs/**,**/documentation/**}' # React Web SDK (optimization-react-web) implementation E2E coverage scope. e2e_react_web_sdk: - '{implementations/react-web-sdk/**,lib/**,packages/web/frameworks/react-web-sdk/**,packages/web/web-sdk/**,packages/web/preview-panel/**,packages/universal/core-sdk/**,packages/universal/api-client/**,packages/universal/api-schemas/**,package.json,pnpm-lock.yaml,.github/workflows/main-pipeline.yaml}' @@ -544,8 +550,8 @@ jobs: path: pkgs - run: pnpm store prune - run: pnpm run implementation:web-sdk_react -- implementation:install -- --no-frozen-lockfile - - run: - pnpm run implementation:web-sdk_react -- implementation:playwright:install -- --with-deps + - run: pnpm --dir lib/e2e-web install --no-frozen-lockfile + - run: pnpm --dir lib/e2e-web run setup:e2e - run: pnpm run implementation:web-sdk_react -- implementation:test:e2e:run - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -553,8 +559,56 @@ jobs: with: name: ci-results-web-sdk_react path: | - ./implementations/web-sdk_react/playwright-report/ - ./implementations/web-sdk_react/test-results/ + ./lib/e2e-web/playwright-report/ + ./lib/e2e-web/test-results/ + retention-days: 1 + + e2e-web-sdk_angular: + name: πŸ…°οΈ E2E Angular + Web SDK + runs-on: namespace-profile-linux-8-vcpu-16-gb-ram-optimal + timeout-minutes: 15 + needs: [setup, changes, build] + if: needs.changes.outputs.e2e_web_sdk_angular == 'true' + steps: + - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + + - name: Create .env from .env.example + run: cp implementations/web-sdk_angular/.env.example implementations/web-sdk_angular/.env + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + + - uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 + + - name: Set up caches (Namespace) + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + with: + cache: | + pnpm + playwright + apt + + - run: pnpm install --prefer-offline --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sdk-package-tarballs + path: pkgs + - run: pnpm store prune + - run: + pnpm run implementation:web-sdk_angular -- implementation:install -- --no-frozen-lockfile + - run: pnpm --dir lib/e2e-web install --no-frozen-lockfile + - run: pnpm --dir lib/e2e-web run setup:e2e + - run: pnpm run implementation:web-sdk_angular -- implementation:test:e2e:run + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ !cancelled() }} + with: + name: ci-results-web-sdk_angular + path: | + ./lib/e2e-web/playwright-report/ + ./lib/e2e-web/test-results/ retention-days: 1 e2e-react-web-sdk: diff --git a/implementations/web-sdk_angular/.env.example b/implementations/web-sdk_angular/.env.example index b5612b791..ebec51cfb 100644 --- a/implementations/web-sdk_angular/.env.example +++ b/implementations/web-sdk_angular/.env.example @@ -13,3 +13,4 @@ PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id" PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000" PUBLIC_CONTENTFUL_BASE_PATH="contentful" PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="true" +PUBLIC_OPTIMIZATION_LOG_LEVEL="" diff --git a/implementations/web-sdk_angular/.gitignore b/implementations/web-sdk_angular/.gitignore index de059b7be..4e108db35 100644 --- a/implementations/web-sdk_angular/.gitignore +++ b/implementations/web-sdk_angular/.gitignore @@ -11,6 +11,10 @@ dist/ .env .env*.local +# generated from .env by generate:env +src/environments/ + + # logs produced by launch-reference-app.sh logs/ diff --git a/implementations/web-sdk_angular/angular.json b/implementations/web-sdk_angular/angular.json index 92e1c3d04..43f6e508f 100644 --- a/implementations/web-sdk_angular/angular.json +++ b/implementations/web-sdk_angular/angular.json @@ -17,14 +17,20 @@ "browser": "src/main.ts", "tsConfig": "tsconfig.json", "assets": [], - "styles": ["src/styles.css"] + "styles": ["src/styles.css"], + "allowedCommonJsDependencies": [ + "lodash", + "contentful-sdk-core", + "qs", + "json-stringify-safe" + ] }, "configurations": { "production": { "budgets": [ { "type": "initial", - "maximumWarning": "500kB", + "maximumWarning": "600kB", "maximumError": "1MB" } ], diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json index c41f6c0a2..4269e5269 100644 --- a/implementations/web-sdk_angular/package.json +++ b/implementations/web-sdk_angular/package.json @@ -5,11 +5,20 @@ "description": "Reference implementation of @contentful/optimization-web for Angular applications.", "license": "MIT", "scripts": { - "dev": "ng serve", + "generate:env": "pnpm exec tsx scripts/generate-env.ts", + "dev": "pnpm generate:env && ng serve", "build": "ng build", "clean": "rimraf ./dist", + "serve": "pnpm serve:mocks && pnpm serve:app", "serve:mocks": "pm2 start --name web-sdk_angular-mocks \"pnpm --dir ../../lib/mocks serve\"", "serve:mocks:stop": "pm2 stop web-sdk_angular-mocks && pm2 delete web-sdk_angular-mocks", + "serve:e2e": "pnpm dev", + "serve:app": "pm2 start --name web-sdk_angular-app \"pnpm dev\"", + "serve:app:stop": "pm2 stop web-sdk_angular-app && pm2 delete web-sdk_angular-app", + "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", + "test:e2e": "IMPLEMENTATION=web-sdk_angular APP_PORT=4200 pnpm --dir ../../lib/e2e-web test", + "test:e2e:ui": "IMPLEMENTATION=web-sdk_angular APP_PORT=4200 pnpm --dir ../../lib/e2e-web test:ui", + "test:e2e:report": "pnpm --dir ../../lib/e2e-web test:report", "test:unit": "echo \"No unit tests necessary\"", "typecheck": "tsc -p tsconfig.json --noEmit" }, @@ -31,6 +40,7 @@ "@angular/cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@types/node": "^24.0.13", + "dotenv": "^17.4.2", "pm2": "^6.0.14", "rimraf": "^6.1.3", "typescript": "~6.0.3" diff --git a/implementations/web-sdk_angular/scripts/generate-env.ts b/implementations/web-sdk_angular/scripts/generate-env.ts new file mode 100644 index 000000000..e18e7d0e1 --- /dev/null +++ b/implementations/web-sdk_angular/scripts/generate-env.ts @@ -0,0 +1,19 @@ +import { parse } from 'dotenv' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' + +const env = parse(readFileSync('.env')) + +const entries = Object.keys(env) + .map((k) => ` ${k}: ${JSON.stringify(env[k])}`) + .join(',\n') + +const content = [ + '// Generated by scripts/generate-env.ts β€” do not edit manually', + 'export const environment = {', + entries, + '}', + '', +].join('\n') + +mkdirSync('src/environments', { recursive: true }) +writeFileSync('src/environments/environment.ts', content) diff --git a/implementations/web-sdk_angular/src/app/app.config.ts b/implementations/web-sdk_angular/src/app/app.config.ts index 416fcbbae..e5c1f4d96 100644 --- a/implementations/web-sdk_angular/src/app/app.config.ts +++ b/implementations/web-sdk_angular/src/app/app.config.ts @@ -6,6 +6,7 @@ import { import { provideRouter } from '@angular/router' import { routes } from './app.routes' import { provideContentfulOptimizationConfig, resolveLogLevel } from './config' +import { environment } from './environment' export const appConfig: ApplicationConfig = { providers: [ @@ -13,24 +14,22 @@ export const appConfig: ApplicationConfig = { provideZonelessChangeDetection(), provideRouter(routes), provideContentfulOptimizationConfig({ - clientId: import.meta.env.PUBLIC_NINETAILED_CLIENT_ID ?? 'mock-client-id', - environment: import.meta.env.PUBLIC_NINETAILED_ENVIRONMENT ?? 'main', - insightsBaseUrl: - import.meta.env.PUBLIC_INSIGHTS_API_BASE_URL ?? 'http://localhost:8000/insights/', - experienceBaseUrl: - import.meta.env.PUBLIC_EXPERIENCE_API_BASE_URL ?? 'http://localhost:8000/experience/', - logLevel: resolveLogLevel(import.meta.env.PUBLIC_OPTIMIZATION_LOG_LEVEL), + clientId: environment.PUBLIC_NINETAILED_CLIENT_ID, + environment: environment.PUBLIC_NINETAILED_ENVIRONMENT, + insightsBaseUrl: environment.PUBLIC_INSIGHTS_API_BASE_URL, + experienceBaseUrl: environment.PUBLIC_EXPERIENCE_API_BASE_URL, + logLevel: resolveLogLevel(environment.PUBLIC_OPTIMIZATION_LOG_LEVEL), locale: 'en-US', app: { name: 'ContentfulOptimization SDK - Angular Web Reference', version: '0.0.0' }, autoTrackEntryInteraction: { views: true, clicks: true, hovers: true }, contentful: { - accessToken: import.meta.env.PUBLIC_CONTENTFUL_TOKEN ?? 'mock-token', - environment: import.meta.env.PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master', - spaceId: import.meta.env.PUBLIC_CONTENTFUL_SPACE_ID ?? 'mock-space-id', - cdaHost: import.meta.env.PUBLIC_CONTENTFUL_CDA_HOST ?? 'localhost:8000', - basePath: import.meta.env.PUBLIC_CONTENTFUL_BASE_PATH ?? 'contentful', + accessToken: environment.PUBLIC_CONTENTFUL_TOKEN, + environment: environment.PUBLIC_CONTENTFUL_ENVIRONMENT, + spaceId: environment.PUBLIC_CONTENTFUL_SPACE_ID, + cdaHost: environment.PUBLIC_CONTENTFUL_CDA_HOST, + basePath: environment.PUBLIC_CONTENTFUL_BASE_PATH, }, - ...(import.meta.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL !== 'false' + ...(environment.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL === 'true' ? { previewPanel: {} } : {}), }), diff --git a/implementations/web-sdk_angular/src/app/app.html b/implementations/web-sdk_angular/src/app/app.html index bb5892125..1cc3f3cd3 100644 --- a/implementations/web-sdk_angular/src/app/app.html +++ b/implementations/web-sdk_angular/src/app/app.html @@ -12,7 +12,7 @@ -
+
diff --git a/implementations/web-sdk_angular/src/app/app.ts b/implementations/web-sdk_angular/src/app/app.ts index b5801f891..a07dbb2c1 100644 --- a/implementations/web-sdk_angular/src/app/app.ts +++ b/implementations/web-sdk_angular/src/app/app.ts @@ -1,6 +1,7 @@ import { Component, inject } from '@angular/core' import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router' import { TrackingLog } from './components/tracking-log' +import { NgLiveUpdates } from './services/live-updates' import { NgContentfulOptimization } from './services/optimization' @Component({ @@ -9,6 +10,10 @@ import { NgContentfulOptimization } from './services/optimization' templateUrl: './app.html', }) export class App { + private readonly liveUpdatesService = inject(NgLiveUpdates) + + protected readonly previewPanelOpen = this.liveUpdatesService.previewPanelVisible + constructor() { // forces singleton creation on startup to wire up page tracking before first route inject(NgContentfulOptimization) diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.html b/implementations/web-sdk_angular/src/app/components/control-panel/index.html index 48e887ce8..e95187cf5 100644 --- a/implementations/web-sdk_angular/src/app/components/control-panel/index.html +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.html @@ -1,21 +1,22 @@
-

SDK state

+

Utilities

Consent - {{ consent() ?? 'undefined' }} + {{ consent() === true ? 'Yes' : consent() === false ? 'No' : 'undefined' }} @if (consent() === true) { } @else { SDK state Active optimizations - {{ optimizationCount() }} + {{ optimizationCount() }}
diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts index e10cb8311..784a65590 100644 --- a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts @@ -21,8 +21,19 @@ export class ControlPanel { protected readonly optimizationCount = computed( () => this.optimization.selectedOptimizations()?.length ?? 0, ) - protected readonly booleanFlag = fromSdkState( - this.optimization.sdk.states.flag('boolean'), + // The flag value is always correct for display regardless of consent β€” even a direct + // fromSdkState subscription would show the right value. The consent gate is purely about + // tracking: states.flag().subscribe() bundles value delivery and trackFlagView in the same + // callback. If the subscription opens before consent, the initial emission is blocked by + // hasConsent(); the value then stays stable so the observable never re-fires and + // trackFlagView is never retried β€” the flag view event is silently lost even after consent + // is granted. Passing a thunk re-triggers the subscription on consent change: on grant a + // fresh subscription opens and immediately emits the current value while consent is held, + // so trackFlagView succeeds; on revoke the subscription is dropped. + // Ideally the core SDK would decouple these: deliver the value unconditionally, fire + // trackFlagView internally on consent change β€” see flag-view-tracking.spec.ts. + protected readonly booleanFlag = fromSdkState(() => + this.consent() === true ? this.optimization.sdk.states.flag('boolean') : undefined, ) protected toggleConsent(): void { diff --git a/implementations/web-sdk_angular/src/app/components/entry-card/index.html b/implementations/web-sdk_angular/src/app/components/entry-card/index.html index 8f3a2fe7a..58e4d4035 100644 --- a/implementations/web-sdk_angular/src/app/components/entry-card/index.html +++ b/implementations/web-sdk_angular/src/app/components/entry-card/index.html @@ -1,6 +1,6 @@ @let scenario = clickScenario(); -
+
@if (scenario === 'ancestor' && !manualTracking()) {
@@ -14,7 +14,8 @@ @if (resolved(); as r) {
@if (richTextHtml(); as html) { diff --git a/implementations/web-sdk_angular/src/app/components/entry-card/index.ts b/implementations/web-sdk_angular/src/app/components/entry-card/index.ts index 2ff8322b5..4645e9297 100644 --- a/implementations/web-sdk_angular/src/app/components/entry-card/index.ts +++ b/implementations/web-sdk_angular/src/app/components/entry-card/index.ts @@ -115,6 +115,7 @@ export class EntryCard { readonly manualTracking = input(false) readonly clickScenario = input(undefined) readonly liveUpdates = input(undefined) + readonly testId = input(undefined) private readonly sanitizer = inject(DomSanitizer) private readonly liveUpdatesService = inject(NgLiveUpdates) @@ -130,6 +131,7 @@ export class EntryCard { manualTracking: this.manualTracking, }) + protected readonly effectiveTestId = computed(() => this.testId() ?? this.resolved().baselineId) protected readonly isVariant = computed(() => this.resolved().optimizationId !== undefined) protected readonly richTextHtml = computed(() => { const { entry } = this.resolved() diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.html b/implementations/web-sdk_angular/src/app/components/tracking-log/index.html index ead12748b..97deb02eb 100644 --- a/implementations/web-sdk_angular/src/app/components/tracking-log/index.html +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.html @@ -1,18 +1,43 @@
-

Tracking

+
+

Tracking

+ {{ rawEventsDisplay() }} events +
@let events = displayEvents(); @if (events.length === 0) {

No events tracked yet

} @else { + + + + + + + + + @for (event of events; track event.key) { - - + + + } diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.scss b/implementations/web-sdk_angular/src/app/components/tracking-log/index.scss index 1e9c45fcb..3b9e4079f 100644 --- a/implementations/web-sdk_angular/src/app/components/tracking-log/index.scss +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.scss @@ -13,6 +13,27 @@ gap: 0.5rem; } +.tracking-log__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + + h2 { + margin: 0; + } +} + +.tracking-log__badge { + font-size: 0.7rem; + font-weight: 600; + background: var(--color-border); + color: var(--color-text-muted); + padding: 0.15rem 0.5rem; + border-radius: 999px; + white-space: nowrap; +} + .tracking-log__empty { font-size: 0.8rem; color: var(--color-text-muted); @@ -33,6 +54,18 @@ padding: 0.15rem 0.4rem 0.15rem 0; } +.tracking-log__thead th { + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + opacity: 0.5; + padding: 0 0.4rem 0.35rem 0; + text-align: left; + white-space: nowrap; +} + .tracking-log__type { font-size: 0.65rem; font-weight: 600; @@ -72,14 +105,18 @@ .tracking-log__label { color: var(--color-text-muted); - overflow: hidden; - text-overflow: ellipsis; + word-break: break-all; +} + +.tracking-log__duration { + font-size: 0.7rem; + color: var(--color-text-muted); white-space: nowrap; - max-width: 200px; + opacity: 0.7; } .tracking-log__count { - font-size: 0.7rem; + font-size: 0.6rem; font-weight: 600; color: var(--color-text-muted); white-space: nowrap; @@ -87,7 +124,7 @@ } .tracking-log__time { - font-size: 0.7rem; + font-size: 0.6rem; color: var(--color-text-muted); white-space: nowrap; opacity: 0.5; diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts index b663d16cd..31d2fe777 100644 --- a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts @@ -10,6 +10,9 @@ interface AnalyticsEvent { key: string count: number firedAt: number + hoverDurationMs?: number + viewDurationMs?: number + hoverId?: string } const MS_PER_SECOND = 1000 @@ -19,10 +22,10 @@ const TICK_INTERVAL_SECONDS = 5 function timeAgo(firedAt: number, now: number): string { const s = Math.floor((now - firedAt) / MS_PER_SECOND) - if (s < SECONDS_PER_MINUTE) return `${s}s ago` + if (s < SECONDS_PER_MINUTE) return `${s}s` const m = Math.floor(s / SECONDS_PER_MINUTE) - if (m < MINUTES_PER_HOUR) return `${m}m ago` - return `${Math.floor(m / MINUTES_PER_HOUR)}h ago` + if (m < MINUTES_PER_HOUR) return `${m}m` + return `${Math.floor(m / MINUTES_PER_HOUR)}h` } @Component({ @@ -34,39 +37,73 @@ export class TrackingLog { private readonly optimization = inject(NgContentfulOptimization) private readonly events = signal>(new Map()) + private readonly rawEventsCount = signal(0) private readonly tick = toSignal(interval(TICK_INTERVAL_SECONDS * MS_PER_SECOND), { initialValue: 0, }) + protected readonly rawEventsDisplay = this.rawEventsCount.asReadonly() protected readonly displayEvents = computed(() => { this.tick() const now = Date.now() return [...this.events().values()] .sort((a, b) => b.firedAt - a.firedAt) - .map((e) => ({ ...e, timeAgo: timeAgo(e.firedAt, now) })) + .map((e) => ({ ...e, time: timeAgo(e.firedAt, now) })) }) constructor() { + let pageSeq = 0 + let componentSeq = 0 const sub = this.optimization.sdk.states.eventStream.subscribe((raw) => { + if (raw != null) { + this.rawEventsCount.update((n) => n + 1) + } switch (raw?.type) { case 'page': { const { properties: { url }, } = raw - this.track({ type: 'page', value: url, key: `page-${url}` }) + pageSeq += 1 + const pathname = (() => { + try { + return new URL(url, window.location.origin).pathname + } catch { + return url + } + })() + this.track({ type: 'page', value: pathname, key: `page-${pageSeq}-${url}` }) break } case 'component': { - const { componentId, viewId } = raw - this.track({ - type: viewId ? 'view' : 'comp', - value: componentId, - key: `component-${componentId}`, - }) + const { componentId, viewId, viewDurationMs } = raw + if (viewId) { + this.track({ + type: 'view', + value: componentId, + key: `view-${viewId}`, + viewDurationMs: typeof viewDurationMs === 'number' ? viewDurationMs : undefined, + }) + } else { + componentSeq += 1 + this.track( + { type: 'comp', value: componentId, key: `component-${componentId}-${componentSeq}` }, + `event-component-${componentId}`, + ) + } break } case 'component_hover': { - const { componentId } = raw - this.track({ type: 'hover', value: componentId, key: `component_hover-${componentId}` }) + const { componentId, hoverId, hoverDurationMs } = raw + if (hoverId) { + this.track({ + type: 'hover', + value: componentId, + key: `component_hover-hover-${hoverId}`, + hoverDurationMs: typeof hoverDurationMs === 'number' ? hoverDurationMs : undefined, + hoverId, + }) + } else { + this.track({ type: 'hover', value: componentId, key: `component_hover-${componentId}` }) + } break } case 'component_click': { @@ -83,13 +120,16 @@ export class TrackingLog { }) } - private track(event: Omit): void { + private track( + event: Omit, + testId?: string, + ): void { const { key } = event this.events.update((map) => { const existing = map.get(key) return new Map(map).set(key, { ...event, - testId: `event-${key}`, + testId: testId ?? `event-${key}`, count: (existing?.count ?? 0) + 1, firedAt: Date.now(), }) diff --git a/implementations/web-sdk_angular/src/app/environment.ts b/implementations/web-sdk_angular/src/app/environment.ts new file mode 100644 index 000000000..91c2089dd --- /dev/null +++ b/implementations/web-sdk_angular/src/app/environment.ts @@ -0,0 +1,5 @@ +/* eslint-disable */ +// @ts-ignore β€” generated by `pnpm generate:env`, see src/environments/environment.ts +import { environment as env } from '../environments/environment' + +export const environment: Record = env diff --git a/implementations/web-sdk_angular/src/app/pages/home/index.html b/implementations/web-sdk_angular/src/app/pages/home/index.html index 23363f3e9..143411941 100644 --- a/implementations/web-sdk_angular/src/app/pages/home/index.html +++ b/implementations/web-sdk_angular/src/app/pages/home/index.html @@ -13,10 +13,10 @@

Web SDK + Angular

Live updates

@let liveEntry = entryFor(liveUpdatesEntryId); @if (liveEntry) { -
- - - +
+ + +
} @@ -24,7 +24,7 @@

Live updates

-

Auto-observed

+

Auto Observed Entries

@for (id of autoIds; track id) { @let entry = entryFor(id); @if (entry) { @@ -35,7 +35,7 @@

Auto-observed

-

Manually-observed

+

Manually Observed Entries

@for (id of manualIds; track id) { @let entry = entryFor(id); @if (entry) { diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.html b/implementations/web-sdk_angular/src/app/pages/page-two/index.html index bfc61be61..798f20feb 100644 --- a/implementations/web-sdk_angular/src/app/pages/page-two/index.html +++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.html @@ -1,32 +1,35 @@ -@if (entries.isLoading()) { -

Loading entries…

-} @else { - +
+ - + -
- @if (autoEntry(); as entry) { -
-
-

Auto-observed

-
-
- -
-
- } @if (manualEntry(); as entry) { -
-
-

Manually-observed

-
-
- -
-
+ @if (entries.isLoading()) { +

Loading entries…

+ } @else { +
+ @if (autoEntry(); as entry) { +
+
+

Auto-observed

+
+
+ +
+
+ } @if (manualEntry(); as entry) { +
+
+

Manually-observed

+
+
+ +
+
+ } +
}
-} diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts index a2563ab3b..f44c3a452 100644 --- a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts +++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts @@ -1,4 +1,5 @@ import { Component, inject } from '@angular/core' +import { RouterLink } from '@angular/router' import { ControlPanel } from '../../components/control-panel' import { EntryCard } from '../../components/entry-card' import { FIXTURES } from '../../fixtures' @@ -10,7 +11,7 @@ const PAGE_TWO_COMPONENT_ID = 'page-two-conversion' @Component({ selector: 'app-page-two', - imports: [EntryCard, ControlPanel], + imports: [EntryCard, ControlPanel, RouterLink], templateUrl: './index.html', host: { style: 'display: contents' }, }) diff --git a/implementations/web-sdk_angular/src/app/services/entry.ts b/implementations/web-sdk_angular/src/app/services/entry.ts index 8d68a6179..613119cee 100644 --- a/implementations/web-sdk_angular/src/app/services/entry.ts +++ b/implementations/web-sdk_angular/src/app/services/entry.ts @@ -110,7 +110,12 @@ export function injectContentfulEntry({ const optimization = inject(NgContentfulOptimization) function liveRead(sig: Signal): T { - return isLive() ? sig() : untracked(sig) + if (isLive()) return sig() + // untracked(sig) would snapshot undefined before the SDK responds, permanently + // freezing the computed. The tracked fallback keeps reactivity alive only until + // the first real value arrives; after that untracked(sig) is non-null and the + // reactive dependency is never taken. + return untracked(sig) ?? sig() } const variant = computed(() => { diff --git a/implementations/web-sdk_angular/src/app/services/live-updates.ts b/implementations/web-sdk_angular/src/app/services/live-updates.ts index 55a9aad15..b8dc44180 100644 --- a/implementations/web-sdk_angular/src/app/services/live-updates.ts +++ b/implementations/web-sdk_angular/src/app/services/live-updates.ts @@ -2,6 +2,12 @@ import { computed, inject, Injectable, signal } from '@angular/core' import { fromSdkState } from '../utils' import { NgContentfulOptimization } from './optimization' +function clickPreviewPanelToggle(): void { + const panel = document.querySelector('ctfl-opt-preview-panel') + const btn = panel?.shadowRoot?.querySelector('button.toggle-drawer') + btn?.click() +} + @Injectable({ providedIn: 'root' }) export class NgLiveUpdates { private readonly sdk = inject(NgContentfulOptimization).sdk @@ -20,4 +26,6 @@ export class NgLiveUpdates { toggle(): void { this.globalLiveUpdatesSignal.update((v) => !v) } + + readonly togglePreviewPanel = clickPreviewPanelToggle } diff --git a/implementations/web-sdk_angular/src/app/services/optimization.ts b/implementations/web-sdk_angular/src/app/services/optimization.ts index 03c1665e3..912f224f0 100644 --- a/implementations/web-sdk_angular/src/app/services/optimization.ts +++ b/implementations/web-sdk_angular/src/app/services/optimization.ts @@ -1,8 +1,7 @@ -import { computed, inject, Injectable, type OnDestroy, type Signal } from '@angular/core' +import { inject, Injectable, type OnDestroy, type Signal } from '@angular/core' import { NavigationEnd, Router } from '@angular/router' import ContentfulOptimization from '@contentful/optimization-web' -import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' -import { Profile } from '@contentful/optimization-web/api-schemas' +import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' import type { Subscription } from 'rxjs' import { filter } from 'rxjs/operators' import type { NgContentfulOptimizationConfig } from '../config' @@ -81,13 +80,7 @@ export class NgContentfulOptimization implements OnDestroy { this.consent = fromSdkState(this.sdk.states.consent) - const rawProfile = fromSdkState(this.sdk.states.profile) - this.profile = computed(() => { - const result = Profile.safeParse(rawProfile()) - if (!result.success) return undefined - // anonymous profiles exist after reset β€” only expose when the user is identified - return result.data.traits.identified ? result.data : undefined - }) + this.profile = fromSdkState(this.sdk.states.profile) this.selectedOptimizations = fromSdkState(this.sdk.states.selectedOptimizations) diff --git a/implementations/web-sdk_angular/src/app/utils.ts b/implementations/web-sdk_angular/src/app/utils.ts index bad4b0976..2ad884c65 100644 --- a/implementations/web-sdk_angular/src/app/utils.ts +++ b/implementations/web-sdk_angular/src/app/utils.ts @@ -1,4 +1,4 @@ -import { DestroyRef, inject, signal, type Signal } from '@angular/core' +import { DestroyRef, effect, inject, signal, type Signal } from '@angular/core' export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null @@ -8,14 +8,35 @@ export interface SdkObservable { subscribe: (fn: (v: T) => void) => { unsubscribe: () => void } } -export function fromSdkState(obs: SdkObservable): Signal { +export function fromSdkState( + source: SdkObservable | (() => SdkObservable | undefined), +): Signal { const s = signal(undefined) const destroyRef = inject(DestroyRef) - const sub = obs.subscribe((v) => { - s.set(v) - }) - destroyRef.onDestroy(() => { - sub.unsubscribe() - }) + + if (typeof source === 'function') { + let sub: { unsubscribe: () => void } | undefined = undefined + + effect(() => { + sub?.unsubscribe() + sub = undefined + s.set(undefined) + const obs = source() + if (obs) + sub = obs.subscribe((v) => { + s.set(v) + }) + }) + + destroyRef.onDestroy(() => sub?.unsubscribe()) + } else { + const sub = source.subscribe((v) => { + s.set(v) + }) + destroyRef.onDestroy(() => { + sub.unsubscribe() + }) + } + return s.asReadonly() } diff --git a/implementations/web-sdk_angular/src/styles.css b/implementations/web-sdk_angular/src/styles.css index 0878faa42..fb9ac7801 100644 --- a/implementations/web-sdk_angular/src/styles.css +++ b/implementations/web-sdk_angular/src/styles.css @@ -144,11 +144,15 @@ nav a.active { main { grid-area: main; min-width: 0; - padding: 1.5rem 24rem 3rem 1.5rem; + padding: 1.5rem 1.5rem 3rem; display: grid; gap: 2.5rem; } +main.preview-panel-open { + padding-right: 28rem; +} + .sections-row { display: grid; grid-template-columns: repeat(3, 1fr); diff --git a/implementations/web-sdk_react/.env.example b/implementations/web-sdk_react/.env.example index 9273d7d65..d31cd0925 100644 --- a/implementations/web-sdk_react/.env.example +++ b/implementations/web-sdk_react/.env.example @@ -13,4 +13,4 @@ PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id" PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000" PUBLIC_CONTENTFUL_BASE_PATH="contentful" -PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="false" +PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="true" diff --git a/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts b/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts deleted file mode 100644 index f5702b24d..000000000 --- a/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { expect, test } from '@playwright/test' - -test.describe('flag view tracking', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('domcontentloaded') - await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - }) - - test('does not emit flag view events without consent', async ({ page }) => { - const flagEvents = page.locator('[data-testid="event-component-boolean"]') - - await expect(flagEvents).toHaveCount(0) - - await page.getByTestId('live-updates-identify-button').click() - await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() - - await expect(flagEvents).toHaveCount(0) - }) - - test('emits flag view events after consent and profile updates', async ({ page }) => { - const flagEvents = page.locator('[data-testid="event-component-boolean"]') - const baselineFlagEventCount = await flagEvents.count() - - await page.getByTestId('consent-button').click() - - await expect - .poll(async () => await flagEvents.count(), { - message: 'consented flag subscription should emit a flag view event', - }) - .toBeGreaterThan(baselineFlagEventCount) - - const afterConsentFlagEventCount = await flagEvents.count() - - await page.getByTestId('live-updates-identify-button').click() - await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() - - await expect - .poll(async () => await flagEvents.count(), { - message: 'profile updates should emit additional flag view events', - }) - .toBeGreaterThan(afterConsentFlagEventCount) - }) -}) diff --git a/implementations/web-sdk_react/package.json b/implementations/web-sdk_react/package.json index 0c57dc0e2..a376a3e99 100644 --- a/implementations/web-sdk_react/package.json +++ b/implementations/web-sdk_react/package.json @@ -11,18 +11,15 @@ "clean": "rimraf ./.rsdoctor ./.rslib ./dist ./coverage ./playwright-report ./test-results .tsbuildinfo", "preview": "pnpm serve:mocks && rsbuild preview", "serve": "pnpm serve:mocks && pnpm serve:app", - "serve:app": "pnpm build && pm2 start --name web-sdk_react-app \"pnpm preview\"", + "serve:e2e": "rsbuild build && rsbuild preview", + "serve:app": "pnpm build && pm2 start --name web-sdk_react-app \"rsbuild preview\"", "serve:app:stop": "pm2 stop web-sdk_react-app && pm2 delete web-sdk_react-app", "serve:mocks": "pm2 start --name web-sdk_react-mocks \"pnpm --dir ../../lib/mocks serve\"", "serve:mocks:stop": "pm2 stop web-sdk_react-mocks && pm2 delete web-sdk_react-mocks", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", - "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:codegen": "playwright codegen", - "test:e2e:report": "playwright show-report", - "test:e2e:ui": "playwright test --ui", - "implementation:playwright:install": "playwright install", - "implementation:playwright:install-deps": "playwright install-deps", - "implementation:setup:e2e": "pnpm implementation:playwright:install && pnpm implementation:playwright:install-deps", + "test:e2e": "IMPLEMENTATION=web-sdk_react pnpm --dir ../../lib/e2e-web test", + "test:e2e:ui": "IMPLEMENTATION=web-sdk_react pnpm --dir ../../lib/e2e-web test:ui", + "test:e2e:report": "pnpm --dir ../../lib/e2e-web test:report", "test:unit": "echo \"No unit tests necessary\"", "typecheck": "tsc --noEmit" }, @@ -37,7 +34,6 @@ "react-router-dom": "7.14.1" }, "devDependencies": { - "@playwright/test": "1.58.2", "@rsbuild/core": "1.7.3", "@rsbuild/plugin-react": "1.4.5", "@rsdoctor/cli": "1.5.2", diff --git a/implementations/web-sdk_react/playwright.config.mjs b/implementations/web-sdk_react/playwright.config.mjs deleted file mode 100644 index 2bb706790..000000000 --- a/implementations/web-sdk_react/playwright.config.mjs +++ /dev/null @@ -1,83 +0,0 @@ -import { defineConfig, devices } from '@playwright/test' -import dotenv from 'dotenv' -import path from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -dotenv.config({ path: path.resolve(__dirname, '.env') }) - -const isCI = Boolean(process.env.CI) - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './e2e', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: isCI, - /* Retry on CI only */ - retries: isCI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: isCI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [['html', { open: 'never' }]], - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: 'http://localhost:3000', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - - /* Record video only when retrying a test for the first time. */ - video: 'on-first-retry', - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'pnpm --filter e2e serve', - // url: 'http://localhost', - // reuseExistingServer: !isCI, - // }, -}) diff --git a/implementations/web-sdk_react/src/App.tsx b/implementations/web-sdk_react/src/App.tsx index 009e16632..8bd81d048 100644 --- a/implementations/web-sdk_react/src/App.tsx +++ b/implementations/web-sdk_react/src/App.tsx @@ -143,44 +143,69 @@ export default function App({ } return ( -
-
+
+
) } diff --git a/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx b/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx index 1a18b1da6..b6c9ecb4e 100644 --- a/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx +++ b/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx @@ -1,10 +1,24 @@ -import { type JSX, useEffect, useRef, useState } from 'react' +import { type JSX, useEffect, useReducer, useRef, useState } from 'react' import { useOptimization } from '../optimization/hooks/useOptimization' import { isRecord } from '../utils/typeGuards' +const MS_PER_SECOND = 1000 +const SECONDS_PER_MINUTE = 60 +const MINUTES_PER_HOUR = 60 +const TICK_INTERVAL_SECONDS = 5 + +function timeAgo(firedAt: number): string { + const s = Math.floor((Date.now() - firedAt) / MS_PER_SECOND) + if (s < SECONDS_PER_MINUTE) return `${s}s` + const m = Math.floor(s / SECONDS_PER_MINUTE) + if (m < MINUTES_PER_HOUR) return `${m}m` + return `${Math.floor(m / MINUTES_PER_HOUR)}h` +} + interface AnalyticsEvent { id: string componentId?: string + firedAt: number hoverId?: string viewId?: string hoverDurationMs?: number @@ -48,6 +62,7 @@ function toAnalyticsEvent(event: unknown, id: string): AnalyticsEvent | undefine return { id, componentId, + firedAt: Date.now(), hoverId, viewId, hoverDurationMs, @@ -102,17 +117,58 @@ function upsertAnalyticsEvent( } const updated = [...previous] - updated[existingIndex] = { ...nextEvent, id: previous[existingIndex].id } + updated[existingIndex] = { + ...nextEvent, + id: previous[existingIndex].id, + firedAt: previous[existingIndex].firedAt, + } return updated } +const BADGE_COLORS: Record = { + page: { background: '#dbeafe', color: '#1d4ed8' }, + view: { background: '#dcfce7', color: '#15803d' }, + comp: { background: '#d1fae5', color: '#065f46' }, + hover: { background: '#f3e8ff', color: '#7e22ce' }, +} +const BADGE_FALLBACK = { background: '#e5e7eb', color: '#374151' } + +function toBadgeType(event: AnalyticsEvent): string { + if (event.type === 'component') return event.viewId ? 'view' : 'comp' + if (event.type === 'component_hover') return 'hover' + return event.type +} + +function toTestId(event: AnalyticsEvent): string { + if (event.viewId) return `event-view-${event.viewId}` + if (event.hoverId) return `event-${event.type}-hover-${event.hoverId}` + if (event.componentId) return `event-${event.type}-${event.componentId}` + return `event-${event.type}-${event.id}` +} + +function toValue(event: AnalyticsEvent): string { + return event.componentId ?? event.pageUrl ?? event.type +} + +function toDuration(event: AnalyticsEvent): number | undefined { + return event.hoverDurationMs ?? event.viewDurationMs +} + export function AnalyticsEventDisplay(): JSX.Element { const { sdk, isReady } = useOptimization() const [events, setEvents] = useState([]) const [rawEventsCount, setRawEventsCount] = useState(0) + const [, tick] = useReducer((n: number) => n + 1, 0) const nextId = useRef(0) + useEffect(() => { + const id = setInterval(tick, MS_PER_SECOND * TICK_INTERVAL_SECONDS) + return () => { + clearInterval(id) + } + }, []) + useEffect(() => { if (!isReady || sdk === undefined) { setEvents([]) @@ -140,38 +196,128 @@ export function AnalyticsEventDisplay(): JSX.Element { return (
-

Analytics Events

-

Events: {events.length}

-

Raw Events: {rawEventsCount}

+
+

Tracking

+ + {rawEventsCount} events + +
{events.length === 0 ?

No events tracked yet

: null} -
    - {events.map((event) => { - const testId = event.viewId - ? `event-view-${event.viewId}` - : event.hoverId - ? `event-${event.type}-hover-${event.hoverId}` - : event.componentId - ? `event-${event.type}-${event.componentId}` - : `event-${event.type}-${event.id}` - - const label = event.componentId - ? typeof event.viewDurationMs === 'number' - ? `${event.type} - Entry/Flag: ${event.componentId} - Duration: ${event.viewDurationMs}ms` - : typeof event.hoverDurationMs === 'number' - ? `${event.type} - Entry/Flag: ${event.componentId} - Hover Duration: ${event.hoverDurationMs}ms` - : `${event.type} - Entry/Flag: ${event.componentId}` - : event.pageUrl - ? `${event.type} - URL: ${event.pageUrl}` - : event.type - - return ( -
  • - {label} -
  • - ) - })} -
+ {events.length > 0 ? ( +
TypeValueDurAge
{{ event.timeAgo }}
{{ event.type }} {{ event.value }} + @if (event.hoverDurationMs !== undefined) { {{ (event.hoverDurationMs / 1000).toFixed(1) }}s + } @else if (event.viewDurationMs !== undefined) { {{ (event.viewDurationMs / + 1000).toFixed(1) }}s } + {{ event.time }} @if (event.count > 1) { Γ—{{ event.count }} }
+ + + {['Type', 'Value', 'Dur', 'Age', ''].map((h) => ( + + ))} + + + + {events.map((event) => { + const badgeType = toBadgeType(event) + const badgeStyle = BADGE_COLORS[badgeType] ?? BADGE_FALLBACK + const duration = toDuration(event) + + return ( + + + + + + + ) + })} + +
+ {h} +
+ + {badgeType} + + + {toValue(event)} + + {duration !== undefined ? `${(duration / MS_PER_SECOND).toFixed(1)}s` : null} + + {timeAgo(event.firedAt)} + +
+ ) : null}
) } diff --git a/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts b/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts index c717ee0f9..b509097e1 100644 --- a/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts +++ b/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts @@ -6,6 +6,7 @@ import type { ResolvedData } from '@contentful/optimization-web/core-sdk' import type { Entry, EntrySkeletonType } from 'contentful' import { useMemo } from 'react' import { useOptimization } from './useOptimization' +import { useOptimizationState } from './useOptimizationState' export interface UseOptimizationResolverResult { resolveEntry: ( @@ -44,6 +45,10 @@ function toStringValue(value: unknown): string { export function useOptimizationResolver(): UseOptimizationResolverResult { const { sdk, isReady } = useOptimization() + // Subscribe to selectedOptimizations so resolveEntry gets a new identity when the + // Experience API responds. Without this, ContentEntry's useMemo would lock in the + // baseline on first render (signal still empty) and never re-resolve on slow browsers. + const { selectedOptimizations } = useOptimizationState(sdk?.states) return useMemo(() => { if (!isReady || sdk === undefined) { @@ -56,12 +61,15 @@ export function useOptimizationResolver(): UseOptimizationResolverResult { return { resolveEntry: ( baselineEntry: Entry, - selectedOptimizations?: SelectedOptimizationArray, + callerSelectedOptimizations?: SelectedOptimizationArray, ): ResolvedData => - sdk.resolveOptimizedEntry(baselineEntry, selectedOptimizations), + sdk.resolveOptimizedEntry( + baselineEntry, + callerSelectedOptimizations ?? selectedOptimizations, + ), getMergeTagValue: (mergeTagEntry: MergeTagEntry): string => toStringValue(sdk.getMergeTagValue(mergeTagEntry)), } - }, [isReady, sdk]) + }, [isReady, sdk, selectedOptimizations]) } diff --git a/implementations/web-sdk_react/src/pages/HomePage.tsx b/implementations/web-sdk_react/src/pages/HomePage.tsx index d05223de9..2130f41b8 100644 --- a/implementations/web-sdk_react/src/pages/HomePage.tsx +++ b/implementations/web-sdk_react/src/pages/HomePage.tsx @@ -24,6 +24,12 @@ function isConsentAccepted(consent: boolean | undefined): boolean { return consent === true } +function consentLabel(consent: boolean | undefined): string { + if (consent === true) return 'Yes' + if (consent === false) return 'No' + return 'undefined' +} + const AUTO_OBSERVED_CLICK_SCENARIO_BY_ENTRY_ID: Readonly> = { '4ib0hsHWoSOnCVdDkizE8d': 'direct', xFwgG3oNaOcjzWiGe4vXo: 'descendant', @@ -50,7 +56,16 @@ export function HomePage({

Utilities

-
+
+ Consent + {consentLabel(consent)} {isConsentAccepted(consent) ? ( ) : ( )} + Identified + {isIdentified ? 'Yes' : 'No'} {!isIdentified ? ( - ) : ( - )} + Live updates + {globalLiveUpdates ? 'ON' : 'OFF'} - + {ENABLE_PREVIEW_PANEL ? ( + <> + Preview panel + + {isPreviewPanelOpen ? 'Open' : 'Closed'} + + + + ) : null} + + Active optimizations + {selectedOptimizationCount} +
- -

Consent: {String(consent)}

-

- Selected Optimizations: {selectedOptimizationCount} -

-

{isIdentified ? 'Yes' : 'No'}

-

{globalLiveUpdates ? 'ON' : 'OFF'}

-

{isPreviewPanelOpen ? 'Open' : 'Closed'}

@@ -118,31 +141,20 @@ export function HomePage({ per-component control is available through the liveUpdates prop.

-
-

Default (inherits global setting)

- -
- -
-

Always On (liveUpdates=true)

- -
- -
-

Locked (liveUpdates=false)

- -
+ + +
diff --git a/implementations/web-sdk_react/src/pages/PageTwoPage.tsx b/implementations/web-sdk_react/src/pages/PageTwoPage.tsx index 596e86c86..f6921ce87 100644 --- a/implementations/web-sdk_react/src/pages/PageTwoPage.tsx +++ b/implementations/web-sdk_react/src/pages/PageTwoPage.tsx @@ -27,7 +27,7 @@ export function PageTwoPage({ consent, entriesById, isIdentified }: PageTwoPageP const handleDemoCta = (): void => { void trackView({ - componentId: 'page-two-demo-cta', + componentId: 'track-conversion-button', viewId: crypto.randomUUID(), viewDurationMs: 0, }) @@ -35,6 +35,9 @@ export function PageTwoPage({ consent, entriesById, isIdentified }: PageTwoPageP return (
+ + Back to Home +

Page Two

Demo route for SPA navigation, route context (/page-two), and conversion-style @@ -68,14 +71,10 @@ export function PageTwoPage({ consent, entriesById, isIdentified }: PageTwoPageP

Conversion Step Demo

-
- - - Back to Home -
) } diff --git a/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx b/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx index d613b72e3..6c69cdbb4 100644 --- a/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx +++ b/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx @@ -63,11 +63,10 @@ export function LiveUpdatesExampleEntry({ const fullLabel = `${text} [Entry: ${resolvedEntry.sys.id}]` return ( -
+

{text}

-

Entry: {resolvedEntry.sys.id}

) } diff --git a/lib/e2e-web/README.md b/lib/e2e-web/README.md new file mode 100644 index 000000000..ac82d09df --- /dev/null +++ b/lib/e2e-web/README.md @@ -0,0 +1,83 @@ +# e2e-web + +Shared Playwright E2E suite for CSR web SDK implementations. The suite owns the test specs as a +single source of truth β€” each implementation runs them against its own dev server via the +`IMPLEMENTATION` env var, so tracking and consent behaviour is verified consistently across React +and Angular without duplicating spec files. + +## Running tests + +```sh +# First-time setup β€” installs Playwright browsers, run once from the repo root +pnpm --dir lib/e2e-web setup:e2e + +# Run the full suite (from an implementation directory) +pnpm test:e2e + +# Open the interactive UI runner +pnpm test:e2e:ui + +# View the last HTML report +pnpm test:e2e:report +``` + +Supported implementations: `web-sdk_react`, `web-sdk_angular`. + +## How it works + +`playwright.config.mjs` uses two env vars to know which app to test: + +- `IMPLEMENTATION` β€” the folder name under `implementations/` (e.g. `web-sdk_angular`). The config + uses this to resolve the implementation directory and registers its `serve:e2e` script as a + Playwright `webServer`. Playwright starts the server automatically before the suite runs and shuts + it down after β€” or reuses it if it is already listening on the port. This means no manual pm2 + process tracking: you can run `pnpm test:e2e` from a cold start and the app comes up, tests run, + and the process is cleaned up without any extra steps. Playwright browsers are installed once in + `lib/e2e-web` via `setup:e2e` and shared across all implementations β€” no per-implementation + install, no duplicating Playwright config, test scripts, or spec files. The value is validated + against `/^[a-z0-9_-]+$/` and resolved to an absolute path before use, so it is never interpolated + directly into a shell command. +- `APP_PORT` β€” the port the app listens on. Defaults to `3000`. Angular dev server uses `4200`, so + it must be set explicitly. Having a configurable port also makes it possible to run E2E suites for + multiple implementations in parallel β€” each on its own port with no conflicts. + +The config also loads the implementation's `.env` file via `process.loadEnvFile()` before the suite +runs, so vars like `PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` are available to both the test process +and the app server without any manual forwarding. + +Each implementation sets both in its own `test:e2e` script: + +```sh +# web-sdk_angular +IMPLEMENTATION=web-sdk_angular APP_PORT=4200 pnpm --dir ../../lib/e2e-web test + +# web-sdk_react +IMPLEMENTATION=web-sdk_react pnpm --dir ../../lib/e2e-web test +``` + +The config also starts the shared mock server (`lib/mocks`) with the same lifecycle β€” spun up before +the suite and reused if already running. Each implementation registers its own `serve:e2e` command, +so the server startup behaviour (port, build step, env) is fully owned by that implementation. + +## Adding a new implementation + +Two changes are needed in the implementation's `package.json` β€” no new dependencies, no new spec +files, no Playwright config: + +```jsonc +// implementations/my-web-sdk/package.json +{ + "scripts": { + // Builds and starts the app on a fixed port for Playwright to target + "serve:e2e": "", + + // Points the shared suite at this implementation; .env is loaded automatically + "test:e2e": "IMPLEMENTATION=my-web-sdk pnpm --dir ../../lib/e2e-web test", + }, +} +``` + +Then align UI labels and `data-testid` attributes with the selectors used in the specs. + +Playwright browsers are installed once in `lib/e2e-web` and shared β€” run `pnpm setup:e2e` there once +before running any implementation's suite for the first time. diff --git a/implementations/web-sdk_react/e2e/displays-identified-user-variants.spec.ts b/lib/e2e-web/e2e/displays-identified-user-variants.spec.ts similarity index 86% rename from implementations/web-sdk_react/e2e/displays-identified-user-variants.spec.ts rename to lib/e2e-web/e2e/displays-identified-user-variants.spec.ts index da15b1f6d..7b6085d44 100644 --- a/implementations/web-sdk_react/e2e/displays-identified-user-variants.spec.ts +++ b/lib/e2e-web/e2e/displays-identified-user-variants.spec.ts @@ -1,21 +1,23 @@ import { expect, test } from '@playwright/test' test.describe('identified user', () => { + test.use({ storageState: { cookies: [], origins: [] } }) + test.beforeEach(async ({ page }) => { await page.goto('/') await page.waitForLoadState('domcontentloaded') await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() await page.getByTestId('consent-button').click() - await expect(page.getByTestId('consent-status')).toHaveText('Consent: true') + await expect(page.getByTestId('consent-status')).toHaveText('Yes') - await page.getByTestId('live-updates-identify-button').click() - await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() + await page.getByTestId('identify-button').click() + await expect(page.getByTestId('reset-button')).toBeVisible() await page.reload() await page.waitForLoadState('domcontentloaded') await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() + await expect(page.getByTestId('reset-button')).toBeVisible() }) test('displays common variants', async ({ page }) => { @@ -70,14 +72,14 @@ test.describe('identified user', () => { }) test('reset persists unidentified state across reload', async ({ page }) => { - await page.getByTestId('live-updates-reset-button').click() - await expect(page.getByTestId('live-updates-identify-button')).toBeVisible() + await page.getByTestId('reset-button').click() + await expect(page.getByTestId('identify-button')).toBeVisible() await page.reload() await page.waitForLoadState('domcontentloaded') await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - await expect(page.getByTestId('live-updates-identify-button')).toBeVisible() + await expect(page.getByTestId('identify-button')).toBeVisible() await expect( page.getByText('This is a baseline content entry for all identified or unidentified users.'), ).toBeVisible() diff --git a/implementations/web-sdk_react/e2e/displays-unidentified-user-variants.spec.ts b/lib/e2e-web/e2e/displays-unidentified-user-variants.spec.ts similarity index 97% rename from implementations/web-sdk_react/e2e/displays-unidentified-user-variants.spec.ts rename to lib/e2e-web/e2e/displays-unidentified-user-variants.spec.ts index 853909379..bef5fb73b 100644 --- a/implementations/web-sdk_react/e2e/displays-unidentified-user-variants.spec.ts +++ b/lib/e2e-web/e2e/displays-unidentified-user-variants.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from '@playwright/test' test.describe('unidentified user', () => { + test.use({ storageState: { cookies: [], origins: [] } }) + test.beforeEach(async ({ page }) => { await page.goto('/') await page.waitForLoadState('domcontentloaded') diff --git a/implementations/web-sdk_react/e2e/entry-click-tracking.spec.ts b/lib/e2e-web/e2e/entry-click-tracking.spec.ts similarity index 100% rename from implementations/web-sdk_react/e2e/entry-click-tracking.spec.ts rename to lib/e2e-web/e2e/entry-click-tracking.spec.ts diff --git a/implementations/web-sdk_react/e2e/entry-hover-tracking.spec.ts b/lib/e2e-web/e2e/entry-hover-tracking.spec.ts similarity index 64% rename from implementations/web-sdk_react/e2e/entry-hover-tracking.spec.ts rename to lib/e2e-web/e2e/entry-hover-tracking.spec.ts index 83500179c..7daa2951f 100644 --- a/implementations/web-sdk_react/e2e/entry-hover-tracking.spec.ts +++ b/lib/e2e-web/e2e/entry-hover-tracking.spec.ts @@ -18,35 +18,19 @@ const hoverScenarios: HoverScenario[] = [ }, ] -function parseHoverDurationMs(label: string): number { - const match = /Hover Duration:\s*(\d+)ms/.exec(label) - if (!match?.[1]) return Number.NaN - - return Number.parseInt(match[1], 10) -} - -function parseHoverId(testId: string | null): string | undefined { - if (!testId) return undefined - - const prefix = 'event-component_hover-hover-' - return testId.startsWith(prefix) ? testId.slice(prefix.length) : undefined -} - async function movePointerAwayFromEntries(page: Page): Promise { await page.getByRole('heading', { name: 'Utilities' }).hover() } -async function readResolvedEntryId(page: Page): Promise { - const entryId = await page - .getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`) - .getAttribute('data-ctfl-entry-id') - - return entryId ?? '' +async function readHoverEventId(page: Page): Promise { + return (await page.locator('[data-hover-id]').first().getAttribute('data-hover-id')) ?? '' } async function readHoverDurationMs(page: Page, hoverId: string): Promise { - const label = await page.getByTestId(`event-component_hover-hover-${hoverId}`).innerText() - return parseHoverDurationMs(label) + const value = await page + .locator(`[data-hover-id="${hoverId}"]`) + .getAttribute('data-hover-duration-ms') + return value !== null ? Number.parseInt(value, 10) : Number.NaN } test.describe('entry hover tracking', () => { @@ -67,7 +51,7 @@ test.describe('entry hover tracking', () => { await movePointerAwayFromEntries(page) } - await expect(page.locator('[data-testid^="event-component_hover-hover-"]')).toHaveCount(0) + await expect(page.locator('[data-hover-id]')).toHaveCount(0) }) test('emits entry hover events for entry container and descendant hovers after consent', async ({ @@ -75,14 +59,7 @@ test.describe('entry hover tracking', () => { }) => { await page.getByTestId('consent-button').click() - await expect - .poll(async () => await readResolvedEntryId(page), { - message: 'resolved entry id should be available', - }) - .not.toEqual('') - const resolvedEntryId = await readResolvedEntryId(page) - - const hoverEvents = page.locator('[data-testid^="event-component_hover-hover-"]') + const hoverEvents = page.locator('[data-hover-id]') for (const scenario of hoverScenarios) { const target = page.getByTestId(scenario.hoverTargetTestId) @@ -97,7 +74,6 @@ test.describe('entry hover tracking', () => { }) .toBeGreaterThan(baselineCount) - await expect(hoverEvents.first()).toContainText(`Entry/Flag: ${resolvedEntryId}`) await movePointerAwayFromEntries(page) } @@ -109,23 +85,14 @@ test.describe('entry hover tracking', () => { }) => { await page.getByTestId('consent-button').click() - await expect - .poll(async () => await readResolvedEntryId(page), { - message: 'resolved entry id should be available', - }) - .not.toEqual('') - const resolvedEntryId = await readResolvedEntryId(page) - const target = page.getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`) await target.scrollIntoViewIfNeeded() await target.hover() - const hoverEvent = page.locator('[data-testid^="event-component_hover-hover-"]').first() - await expect(hoverEvent).toBeVisible() - await expect(hoverEvent).toContainText(`Entry/Flag: ${resolvedEntryId}`) + const hoverEvents = page.locator('[data-hover-id]') + await expect(hoverEvents.first()).toBeVisible() - const hoverEventTestId = await hoverEvent.getAttribute('data-testid') - const hoverId = parseHoverId(hoverEventTestId) + const hoverId = await readHoverEventId(page) expect(hoverId).toBeTruthy() if (!hoverId) return diff --git a/implementations/web-sdk_react/e2e/events-consent-gating.spec.ts b/lib/e2e-web/e2e/events-consent-gating.spec.ts similarity index 95% rename from implementations/web-sdk_react/e2e/events-consent-gating.spec.ts rename to lib/e2e-web/e2e/events-consent-gating.spec.ts index 80fb3e0c3..d2857b107 100644 --- a/implementations/web-sdk_react/e2e/events-consent-gating.spec.ts +++ b/lib/e2e-web/e2e/events-consent-gating.spec.ts @@ -32,7 +32,7 @@ test.describe('consent gating', () => { await expect(pageEvents.first()).toBeVisible() - await page.getByRole('button', { name: 'Accept Consent' }).click() + await page.getByTestId('consent-button').click() await scrollThroughEntries(page) await expect.poll(async () => await viewEvents.count()).toBeGreaterThan(0) diff --git a/lib/e2e-web/e2e/flag-view-tracking.spec.ts b/lib/e2e-web/e2e/flag-view-tracking.spec.ts new file mode 100644 index 000000000..2f5043045 --- /dev/null +++ b/lib/e2e-web/e2e/flag-view-tracking.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test' + +test.describe('flag view tracking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + }) + + test('does not emit flag view events without consent', async ({ page }) => { + const flagEvents = page.locator('[data-testid="event-component-boolean"]') + + // no events on page load + await expect(flagEvents).toHaveCount(0) + + // identifying without consent must not record flag exposure + await page.getByTestId('identify-button').click() + await expect(page.getByTestId('reset-button')).toBeVisible() + + await expect(flagEvents).toHaveCount(0) + }) + + test('emits flag view event on consent grant for identified user β€” anonymous exposure must be recorded for complete experiment analysis', async ({ + page, + }) => { + const flagEvents = page.locator('[data-testid="event-component-boolean"]') + + // identify first β€” flag value updates but no event because no consent yet + await page.getByTestId('identify-button').click() + await expect(page.getByTestId('reset-button')).toBeVisible() + await expect(flagEvents).toHaveCount(0) + + // consent opens a fresh subscription which emits the current flag value immediately, + // recording exposure even though identity was already established before consent + await page.getByTestId('consent-button').click() + + await expect + .poll(async () => await flagEvents.count(), { + message: 'granting consent after identify should still emit a flag view event', + }) + .toBeGreaterThan(0) + }) + + test('emits flag view event on consent for anonymous user, then again after identify β€” each profile change re-records exposure', async ({ + page, + }) => { + const flagEvents = page.locator('[data-testid="event-component-boolean"]') + const baselineFlagEventCount = await flagEvents.count() + + // consent while anonymous β€” records the anonymous user's exposure + await page.getByTestId('consent-button').click() + + await expect + .poll(async () => await flagEvents.count(), { + message: 'consented flag subscription should emit a flag view event', + }) + .toBeGreaterThan(baselineFlagEventCount) + + const afterConsentFlagEventCount = await flagEvents.count() + + // identify β€” profile change may update the flag value, re-recording exposure for the + // now-identified user + await page.getByTestId('identify-button').click() + await expect(page.getByTestId('reset-button')).toBeVisible() + + await expect + .poll(async () => await flagEvents.count(), { + message: 'profile updates should emit additional flag view events', + }) + .toBeGreaterThan(afterConsentFlagEventCount) + }) +}) diff --git a/implementations/web-sdk_react/e2e/live-updates.spec.ts b/lib/e2e-web/e2e/live-updates.spec.ts similarity index 76% rename from implementations/web-sdk_react/e2e/live-updates.spec.ts rename to lib/e2e-web/e2e/live-updates.spec.ts index be4a212e2..80c07c3be 100644 --- a/implementations/web-sdk_react/e2e/live-updates.spec.ts +++ b/lib/e2e-web/e2e/live-updates.spec.ts @@ -3,12 +3,11 @@ import { type Locator, type Page, expect, test } from '@playwright/test' const isPreviewPanelEnabled = process.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL === 'true' async function getEntryId(locator: Locator): Promise { - const text = await locator.innerText() - return text.replace('Entry: ', '').trim() + return (await locator.getAttribute('data-test-entry-id')) ?? '' } async function identify(page: Page): Promise { - await page.getByTestId('live-updates-identify-button').click() + await page.getByTestId('identify-button').click() await expect(page.getByTestId('identified-status')).toHaveText('Yes') } @@ -32,7 +31,7 @@ test.describe('live updates behavior', () => { await expect .poll(async () => { const text = await page.getByTestId('selected-optimizations-count').innerText() - const value = Number.parseInt(text.replace('Selected Optimizations: ', ''), 10) + const value = Number.parseInt(text, 10) return Number.isNaN(value) ? 0 : value }) .toBeGreaterThan(0) @@ -41,12 +40,13 @@ test.describe('live updates behavior', () => { test('default behavior locks to first value when global live updates is OFF', async ({ page, }) => { - const initialDefaultEntryId = await getEntryId(page.getByTestId('entry-id-live-default')) + const initialDefaultEntryId = await getEntryId(page.getByTestId('content-live-default')) await identify(page) - await expect(page.getByTestId('entry-id-live-default')).toHaveText( - `Entry: ${initialDefaultEntryId}`, + await expect(page.getByTestId('content-live-default')).toHaveAttribute( + 'data-test-entry-id', + initialDefaultEntryId, ) }) @@ -56,34 +56,35 @@ test.describe('live updates behavior', () => { await page.getByTestId('toggle-global-live-updates-button').click() await expect(page.getByTestId('global-live-updates-status')).toHaveText('ON') - const initialDefaultEntryId = await getEntryId(page.getByTestId('entry-id-live-default')) - const initialLockedEntryId = await getEntryId(page.getByTestId('entry-id-live-locked')) + const initialDefaultEntryId = await getEntryId(page.getByTestId('content-live-default')) + const initialLockedEntryId = await getEntryId(page.getByTestId('content-live-locked')) await identify(page) await expect - .poll(async () => await getEntryId(page.getByTestId('entry-id-live-default'))) + .poll(async () => await getEntryId(page.getByTestId('content-live-default'))) .not.toBe(initialDefaultEntryId) - await expect(page.getByTestId('entry-id-live-locked')).toHaveText( - `Entry: ${initialLockedEntryId}`, + await expect(page.getByTestId('content-live-locked')).toHaveAttribute( + 'data-test-entry-id', + initialLockedEntryId, ) }) test('per-component liveUpdates=true updates even when global live updates is OFF', async ({ page, }) => { - const initialLiveEntryId = await getEntryId(page.getByTestId('entry-id-live-enabled')) + const initialLiveEntryId = await getEntryId(page.getByTestId('content-live-enabled')) await identify(page) await expect - .poll(async () => await getEntryId(page.getByTestId('entry-id-live-enabled'))) + .poll(async () => await getEntryId(page.getByTestId('content-live-enabled'))) .not.toBe(initialLiveEntryId) }) test('preview-panel override enables updates for locked entries', async ({ page }) => { test.skip(!isPreviewPanelEnabled, 'Preview panel is disabled for this build.') - const initialLockedEntryId = await getEntryId(page.getByTestId('entry-id-live-locked')) + const initialLockedEntryId = await getEntryId(page.getByTestId('content-live-locked')) await page.getByTestId('simulate-preview-panel-button').click() await expect(page.getByTestId('preview-panel-status')).toHaveText('Open') @@ -91,7 +92,7 @@ test.describe('live updates behavior', () => { await identify(page) await expect - .poll(async () => await getEntryId(page.getByTestId('entry-id-live-locked'))) + .poll(async () => await getEntryId(page.getByTestId('content-live-locked'))) .not.toBe(initialLockedEntryId) }) @@ -112,17 +113,13 @@ test.describe('live updates behavior', () => { test('screen controls identify and reset user', async ({ page }) => { await expect(page.getByTestId('identified-status')).toHaveText('No') - await page.getByTestId('live-updates-identify-button').click() + await page.getByTestId('identify-button').click() await expect(page.getByTestId('identified-status')).toHaveText('Yes') - await page.getByTestId('live-updates-reset-button').click() + await page.getByTestId('reset-button').click() await expect(page.getByTestId('identified-status')).toHaveText('No') }) test('renders default, enabled, and locked examples', async ({ page }) => { - await expect(page.getByTestId('live-updates-default')).toBeVisible() - await expect(page.getByTestId('live-updates-enabled')).toBeVisible() - await expect(page.getByTestId('live-updates-locked')).toBeVisible() - await expect(page.getByTestId('content-live-default')).toBeVisible() await expect(page.getByTestId('content-live-enabled')).toBeVisible() await expect(page.getByTestId('content-live-locked')).toBeVisible() @@ -130,8 +127,5 @@ test.describe('live updates behavior', () => { await expect(page.getByTestId('entry-text-live-default')).toBeVisible() await expect(page.getByTestId('entry-text-live-enabled')).toBeVisible() await expect(page.getByTestId('entry-text-live-locked')).toBeVisible() - await expect(page.getByTestId('entry-id-live-default')).toBeVisible() - await expect(page.getByTestId('entry-id-live-enabled')).toBeVisible() - await expect(page.getByTestId('entry-id-live-locked')).toBeVisible() }) }) diff --git a/implementations/web-sdk_react/e2e/navigation-page-events.spec.ts b/lib/e2e-web/e2e/navigation-page-events.spec.ts similarity index 88% rename from implementations/web-sdk_react/e2e/navigation-page-events.spec.ts rename to lib/e2e-web/e2e/navigation-page-events.spec.ts index 9f9c36f64..66651d0d5 100644 --- a/implementations/web-sdk_react/e2e/navigation-page-events.spec.ts +++ b/lib/e2e-web/e2e/navigation-page-events.spec.ts @@ -6,14 +6,10 @@ async function getRecentPageEventUrls(page: Page): Promise { const urls: string[] = [] for (let index = 0; index < count; index += 1) { - const text = await pageEvents.nth(index).innerText() - const marker = 'URL: ' - const markerIndex = text.indexOf(marker) - if (markerIndex === -1) { - continue + const url = await pageEvents.nth(index).getAttribute('data-page-url') + if (url !== null) { + urls.push(url) } - - urls.push(text.slice(markerIndex + marker.length).trim()) } return urls diff --git a/implementations/web-sdk_react/e2e/offline-queue-recovery.spec.ts b/lib/e2e-web/e2e/offline-queue-recovery.spec.ts similarity index 80% rename from implementations/web-sdk_react/e2e/offline-queue-recovery.spec.ts rename to lib/e2e-web/e2e/offline-queue-recovery.spec.ts index 9634bebe3..75a0ddd43 100644 --- a/implementations/web-sdk_react/e2e/offline-queue-recovery.spec.ts +++ b/lib/e2e-web/e2e/offline-queue-recovery.spec.ts @@ -1,13 +1,8 @@ import { type BrowserContext, type Page, expect, test } from '@playwright/test' -function parseCounterValue(text: string): number { - const match = /:\s*(\d+)/.exec(text) - return match?.[1] ? Number.parseInt(match[1], 10) : 0 -} - async function getRawEventsCount(page: Page): Promise { const text = await page.getByTestId('raw-events-count').innerText() - return parseCounterValue(text) + return Number.parseInt(text, 10) } async function expectRawEventsToIncrease(page: Page, baselineCount: number): Promise { @@ -20,7 +15,6 @@ async function setOffline(context: BrowserContext, offline: boolean): Promise { await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - await expect(page.getByTestId('events-count')).toBeVisible() await expect(page.getByTestId('raw-events-count')).toBeVisible() } @@ -43,7 +37,7 @@ test.describe('offline queue and recovery', () => { await setOffline(context, true) await page.getByTestId('link-page-two').click() await expect(page.getByTestId('page-two-view')).toBeVisible() - await page.getByTestId('page-two-demo-cta').click() + await page.getByTestId('track-conversion-button').click() await expectRawEventsToIncrease(page, baselineCount) }) @@ -55,7 +49,7 @@ test.describe('offline queue and recovery', () => { await setOffline(context, false) await page.getByTestId('link-back-home').click() await waitForBaseUi(page) - await expect(page.getByTestId('live-updates-identify-button')).toBeVisible() + await expect(page.getByTestId('identify-button')).toBeVisible() }) test('remains stable across rapid network state changes', async ({ context, page }) => { @@ -65,11 +59,11 @@ test.describe('offline queue and recovery', () => { await setOffline(context, false) await waitForBaseUi(page) - await expect(page.getByTestId('live-updates-identify-button')).toBeVisible() + await expect(page.getByTestId('identify-button')).toBeVisible() const baselineCount = await getRawEventsCount(page) await page.getByTestId('link-page-two').click() await expect(page.getByTestId('page-two-view')).toBeVisible() - await page.getByTestId('page-two-demo-cta').click() + await page.getByTestId('track-conversion-button').click() await expectRawEventsToIncrease(page, baselineCount) }) @@ -80,12 +74,12 @@ test.describe('offline queue and recovery', () => { const baselineCount = await getRawEventsCount(page) await setOffline(context, true) - await page.getByTestId('live-updates-identify-button').click() + await page.getByTestId('identify-button').click() await expectRawEventsToIncrease(page, baselineCount) await expect(page.getByTestId('identified-status')).toHaveText('No') await setOffline(context, false) - await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() + await expect(page.getByTestId('reset-button')).toBeVisible() await expect(page.getByTestId('identified-status')).toHaveText('Yes') }) }) diff --git a/lib/e2e-web/package.json b/lib/e2e-web/package.json new file mode 100644 index 000000000..b6ddfe406 --- /dev/null +++ b/lib/e2e-web/package.json @@ -0,0 +1,20 @@ +{ + "name": "e2e-web", + "private": true, + "version": "0.0.0", + "description": "Shared Playwright E2E suite for CSR web SDK implementations.", + "license": "MIT", + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:report": "playwright show-report", + "playwright:install": "playwright install", + "playwright:install-deps": "playwright install-deps", + "setup:e2e": "pnpm playwright:install && pnpm playwright:install-deps", + "test:unit": "echo \"No unit tests necessary\"" + }, + "devDependencies": { + "@playwright/test": "catalog:", + "@types/node": "catalog:" + } +} diff --git a/lib/e2e-web/playwright.config.mjs b/lib/e2e-web/playwright.config.mjs new file mode 100644 index 000000000..f0f6105b0 --- /dev/null +++ b/lib/e2e-web/playwright.config.mjs @@ -0,0 +1,66 @@ +import { defineConfig, devices } from '@playwright/test' +import { resolve } from 'path' + +const isCI = Boolean(process.env.CI) + +const PORT = process.env.APP_PORT ?? 3000 +const BASE_URL = `http://localhost:${PORT}` + +const IMPLEMENTATION = process.env.IMPLEMENTATION +if (IMPLEMENTATION && !/^[a-z0-9_-]+$/.test(IMPLEMENTATION)) { + throw new Error(`Invalid IMPLEMENTATION: ${IMPLEMENTATION}`) +} +const IMPL_DIR = IMPLEMENTATION + ? resolve(import.meta.dirname, '../../implementations', IMPLEMENTATION) + : null + +if (IMPL_DIR) { + try { + process.loadEnvFile(resolve(IMPL_DIR, '.env')) + } catch { + // .env not present is fine + } +} + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: 1, + timeout: 60000, + expect: { timeout: 10_000 }, + reporter: [['html', { open: 'never' }]], + use: { + baseURL: BASE_URL, + // 'on-first-retry' never writes traces locally (0 retries) β€” the UI trace viewer + // hits 404/500 on every snapshot load. Always capture locally so the viewer works. + trace: isCI ? 'on-first-retry' : 'on', + video: 'on-first-retry', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + ], + webServer: [ + { + name: 'Mocks', + command: 'pnpm --dir ../mocks serve', + url: 'http://localhost:8000/health', + reuseExistingServer: true, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, + { + name: 'App', + command: IMPL_DIR ? `pnpm --dir ${IMPL_DIR} serve:e2e` : 'tail -f /dev/null', + url: BASE_URL, + reuseExistingServer: true, + timeout: 60_000, + stdout: 'pipe', + stderr: 'pipe', + }, + ], +}) diff --git a/lib/e2e-web/tsconfig.json b/lib/e2e-web/tsconfig.json new file mode 100644 index 000000000..7dbee1af3 --- /dev/null +++ b/lib/e2e-web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "dom"], + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["e2e/**/*.ts"] +} diff --git a/lib/mocks/src/server.ts b/lib/mocks/src/server.ts index 2cbad4607..f4002bc0c 100644 --- a/lib/mocks/src/server.ts +++ b/lib/mocks/src/server.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-magic-numbers -- testing */ import { createServer } from '@mswjs/http-middleware' +import { HttpResponse, http } from 'msw' import { getHandlers as getContentfulHandlers } from './contentful-handlers' import { getHandlers as getExperienceHandlers } from './experience-handlers' import { getHandlers as getInsightsHandlers } from './insights-handlers' @@ -19,6 +20,7 @@ const EXPERIENCE_BASE_URL = const INSIGHTS_BASE_URL = process.env.INSIGHTS_BASE_URL ?? `${BASE_HOST}:${PORT}${INSIGHTS_PATH}` const app = createServer( + http.get('*/health', () => HttpResponse.text('ok')), ...getContentfulHandlers(`*${CONTENTFUL_PATH}`), ...getExperienceHandlers(`*${EXPERIENCE_PATH}`), ...getInsightsHandlers(`*${INSIGHTS_PATH}`), diff --git a/package.json b/package.json index f5b075a25..e4fe79bd7 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,8 @@ "notices:generate:npm": "bash ./scripts/generate-third-party-notices.sh npm", "notices:generate:swift": "bash ./scripts/generate-third-party-notices.sh swift", "pack:pkgs": "bash ./scripts/pack-pkgs.sh", - "playwright:install": "pnpm run implementation:run -- --all -- implementation:playwright:install", - "playwright:install-deps": "pnpm run implementation:run -- --all -- implementation:playwright:install-deps", + "playwright:install": "pnpm run implementation:run -- --all -- implementation:playwright:install && pnpm --dir lib/e2e-web run playwright:install", + "playwright:install-deps": "pnpm run implementation:run -- --all -- implementation:playwright:install-deps && pnpm --dir lib/e2e-web run playwright:install-deps", "pm2:delete:all": "pm2 delete all", "pm2:list": "pm2 list", "pm2:logs": "pm2 logs", @@ -52,7 +52,8 @@ "setup:e2e:node-sdk+web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- node-sdk+web-sdk implementation:install && pnpm run implementation:run -- node-sdk+web-sdk implementation:setup:e2e", "setup:e2e:react-native-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- react-native-sdk implementation:install && pnpm run implementation:run -- react-native-sdk implementation:setup:e2e", "setup:e2e:react-web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- react-web-sdk implementation:install && pnpm run implementation:run -- react-web-sdk implementation:setup:e2e", - "setup:e2e:web-sdk_react": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_react implementation:install && pnpm run implementation:run -- web-sdk_react implementation:setup:e2e", + "setup:e2e:web-sdk_angular": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_angular implementation:install && pnpm --dir lib/e2e-web setup:e2e", + "setup:e2e:web-sdk_react": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk_react implementation:install && pnpm --dir lib/e2e-web setup:e2e", "setup:e2e:web-sdk": "pnpm run build:pkgs && pnpm run implementation:run -- web-sdk implementation:install && pnpm run implementation:run -- web-sdk implementation:setup:e2e", "test:e2e": "pnpm run setup:e2e && pnpm run implementation:run -- --all -- implementation:test:e2e:run", "test:e2e:ios-sdk": "pnpm run setup:e2e:ios-sdk && pnpm run implementation:ios-sdk -- test:e2e:ios:build:release && IOS_SCHEME=SwiftUI pnpm run implementation:ios-sdk -- test:e2e:ios:run:release && IOS_SCHEME=UIKit pnpm run implementation:ios-sdk -- test:e2e:ios:run:release", @@ -60,6 +61,7 @@ "test:e2e:node-sdk+web-sdk": "pnpm run setup:e2e:node-sdk+web-sdk && pnpm run implementation:run -- node-sdk+web-sdk implementation:test:e2e:run", "test:e2e:react-native-sdk": "pnpm run setup:e2e:react-native-sdk && pnpm run implementation:run -- react-native-sdk implementation:test:e2e:run", "test:e2e:react-web-sdk": "pnpm run setup:e2e:react-web-sdk && pnpm run implementation:run -- react-web-sdk implementation:test:e2e:run", + "test:e2e:web-sdk_angular": "pnpm run setup:e2e:web-sdk_angular && pnpm run implementation:run -- web-sdk_angular implementation:test:e2e:run", "test:e2e:web-sdk_react": "pnpm run setup:e2e:web-sdk_react && pnpm run implementation:run -- web-sdk_react implementation:test:e2e:run", "test:e2e:web-sdk": "pnpm run setup:e2e:web-sdk && pnpm run implementation:run -- web-sdk implementation:test:e2e:run", "size:check": "pnpm --filter @contentful/* --stream size:check", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d793e66e..45e15e95f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,7 +83,7 @@ catalogs: version: 4.3.6 overrides: - '@playwright/test': ^1.57.0 + '@playwright/test': 1.61.0 '@types/node': ^24.0.13 typescript: ^5.8.3 vitest: ^3.2.4 @@ -180,6 +180,15 @@ importers: specifier: ^5.8.3 version: 5.9.3 + lib/e2e-web: + devDependencies: + '@playwright/test': + specifier: 1.61.0 + version: 1.61.0 + '@types/node': + specifier: ^24.0.13 + version: 24.10.13 + lib/mocks: dependencies: '@contentful/optimization-api-schemas': @@ -663,7 +672,7 @@ importers: version: 20.8.9 next: specifier: ^16.2.6 - version: 16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.6(@playwright/test@1.61.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-router-dom: specifier: ^7.14.2 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -2117,6 +2126,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.61.0': + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + engines: {node: '>=18'} + hasBin: true + '@pm2/agent@2.1.1': resolution: {integrity: sha512-0V9ckHWd/HSC8BgAbZSoq8KXUG81X97nSkAxmhKDhmF8vanyaoc1YXwc2KVkbWz82Rg4gjd2n9qiT3i7bdvGrQ==} @@ -4598,6 +4612,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + 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} @@ -6053,7 +6072,7 @@ packages: hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.57.0 + '@playwright/test': 1.61.0 babel-plugin-react-compiler: '*' react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 @@ -6407,6 +6426,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + pm2-axon-rpc@0.7.1: resolution: {integrity: sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==} engines: {node: '>=5'} @@ -9314,6 +9343,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.61.0': + dependencies: + playwright: 1.61.0 + '@pm2/agent@2.1.1': dependencies: async: 3.2.6 @@ -12703,6 +12736,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -14656,7 +14692,7 @@ snapshots: netmask@2.0.2: {} - next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.6(@playwright/test@1.61.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 @@ -14675,6 +14711,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.6 '@next/swc-win32-arm64-msvc': 16.2.6 '@next/swc-win32-x64-msvc': 16.2.6 + '@playwright/test': 1.61.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -15003,6 +15040,14 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.61.0: {} + + playwright@1.61.0: + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + pm2-axon-rpc@0.7.1: dependencies: debug: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f355365f1..eb31478be 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,7 +20,7 @@ catalog: '@contentful/rich-text-html-renderer': ^17.1.6 '@contentful/rich-text-types': ^17.2.5 '@microsoft/api-extractor': ^7.57.7 - '@playwright/test': ^1.57.0 + '@playwright/test': 1.61.0 '@preact/signals-core': ^1.13.0 '@rsbuild/core': ^1.7.3 '@rstest/core': ^0.8.5