Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a92b176
feat(rendering): handle rendering concerns framework-agnostic; fix vi…
nalchevanidze Jun 18, 2026
4f4b774
Merge branch 'main' into NT-3466-shared
nalchevanidze Jun 18, 2026
d999fe4
refactor(web-sdk_angular): rename testIdPrefix to testId on EntryCard
nalchevanidze Jun 18, 2026
347d703
docs(web-sdk_angular): improve liveRead comment to explain the ?? fal…
nalchevanidze Jun 18, 2026
a6b566f
refactor(web-sdk_angular): generalize fromSdkState to accept thunk or…
nalchevanidze Jun 18, 2026
b7d684b
docs(e2e-web): add intent comments to flag-view-tracking consent tests
nalchevanidze Jun 18, 2026
6628a57
refactor(e2e-web): move flag-view-tracking intent into test titles
nalchevanidze Jun 18, 2026
344d3b5
docs(web-sdk_angular): clarify booleanFlag consent gate is for tracki…
nalchevanidze Jun 18, 2026
c2bd146
docs(e2e-web): add README with purpose, setup, and how IMPLEMENTATION…
nalchevanidze Jun 18, 2026
83e9512
docs(e2e-web): expand README with IMPLEMENTATION/APP_PORT rationale a…
nalchevanidze Jun 18, 2026
d7322dd
docs(e2e-web): clarify adding new implementation steps and shared setup
nalchevanidze Jun 18, 2026
b01437d
docs(e2e-web): simplify adding new implementation example
nalchevanidze Jun 18, 2026
62f1397
refactor(e2e-web): remove redundant implementation:setup:e2e aliases;…
nalchevanidze Jun 18, 2026
c567631
fix(e2e-web): update root setup:e2e scripts to call lib/e2e-web directly
nalchevanidze Jun 18, 2026
f3c4779
fix(e2e-web,implementations): use === 'true' for env var boolean checks
nalchevanidze Jun 19, 2026
3f16a7f
fix(e2e-web): load implementation .env before suite runs
nalchevanidze Jun 19, 2026
aa3d186
fix(web-sdk_angular): always enable preview panel
nalchevanidze Jun 19, 2026
a827ba0
revert(web-sdk_angular): restore preview panel gate to !== 'false'
nalchevanidze Jun 19, 2026
0fbcf95
Merge branch 'main' into NT-3466-shared
nalchevanidze Jun 19, 2026
b18b4b8
feat(web-sdk_angular): load .env via environment.ts file replacement
nalchevanidze Jun 19, 2026
60d54c5
fix(web-sdk_angular): create environments dir if missing in generate:env
nalchevanidze Jun 19, 2026
8c29b47
fix(web-sdk_angular): type environment stub and use ?? over ||
nalchevanidze Jun 19, 2026
d44089f
feat(web-sdk_angular): use .env as source of truth for e2e via file r…
nalchevanidze Jun 19, 2026
8327f9d
fix(web-sdk_angular): use pnpm tsx over relative node_modules path
nalchevanidze Jun 19, 2026
5c3c672
feat(web-sdk_angular): simplify env loading — drop fileReplacements, …
nalchevanidze Jun 20, 2026
55e65b1
fix(web-sdk_angular): use .env only in generate-env, drop .env.exampl…
nalchevanidze Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions .github/workflows/main-pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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}'
Expand Down Expand Up @@ -544,17 +550,65 @@ 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
if: ${{ !cancelled() }}
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:
Expand Down
1 change: 1 addition & 0 deletions implementations/web-sdk_angular/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
4 changes: 4 additions & 0 deletions implementations/web-sdk_angular/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ dist/
.env
.env*.local

# generated from .env by generate:env
src/environments/


# logs produced by launch-reference-app.sh
logs/

Expand Down
10 changes: 8 additions & 2 deletions implementations/web-sdk_angular/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
],
Expand Down
12 changes: 11 additions & 1 deletion implementations/web-sdk_angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions implementations/web-sdk_angular/scripts/generate-env.ts
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 12 additions & 13 deletions implementations/web-sdk_angular/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,30 @@ 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: [
provideBrowserGlobalErrorListeners(),
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: {} }
: {}),
}),
Expand Down
2 changes: 1 addition & 1 deletion implementations/web-sdk_angular/src/app/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<aside class="app-sidebar">
<app-tracking-log />
</aside>
<main>
<main [class.preview-panel-open]="previewPanelOpen()">
<router-outlet />
</main>
</div>
5 changes: 5 additions & 0 deletions implementations/web-sdk_angular/src/app/app.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
<section class="control-panel">
<h2 class="control-panel__title">SDK state</h2>
<h2 class="control-panel__title">Utilities</h2>

<div class="control-panel__table">
<span
class="control-panel__row-label"
data-testid="consent-status"
data-tooltip="SDK tracking is active only when consent is true"
>Consent</span
>
<span class="control-panel__row-value">{{ consent() ?? 'undefined' }}</span>
<span class="control-panel__row-value" data-testid="consent-status"
>{{ consent() === true ? 'Yes' : consent() === false ? 'No' : 'undefined' }}</span
>
@if (consent() === true) {
<button
class="btn btn--danger btn--sm"
data-testid="unconsent-button"
(click)="toggleConsent()"
>
Withdraw
Revoke
</button>
} @else {
<button
Expand All @@ -29,11 +30,12 @@ <h2 class="control-panel__title">SDK state</h2>

<span
class="control-panel__row-label"
data-testid="identified-status"
data-tooltip="User has been identified with a profile via the identify() call"
>Identified</span
>
<span class="control-panel__row-value">{{ isIdentified() ? 'Yes' : 'No' }}</span>
<span class="control-panel__row-value" data-testid="identified-status"
>{{ isIdentified() ? 'Yes' : 'No' }}</span
>
@if (isIdentified()) {
<button class="btn btn--danger btn--sm" data-testid="reset-button" (click)="reset()">
Reset
Expand All @@ -50,29 +52,34 @@ <h2 class="control-panel__title">SDK state</h2>
data-tooltip="When ON, entries re-resolve and rerender on profile changes"
>Live updates</span
>
<span class="control-panel__row-value"
<span class="control-panel__row-value" data-testid="global-live-updates-status"
>{{ liveUpdatesService.globalLiveUpdates() ? 'ON' : 'OFF' }}</span
>
<button
class="btn btn--sm"
[class.btn--secondary]="!liveUpdatesService.globalLiveUpdates()"
[class.btn--danger]="liveUpdatesService.globalLiveUpdates()"
data-testid="live-updates-toggle"
data-testid="toggle-global-live-updates-button"
(click)="liveUpdatesService.toggle()"
>
{{ liveUpdatesService.globalLiveUpdates() ? 'OFF' : 'ON' }}
</button>

<span
class="control-panel__row-label"
data-testid="preview-panel-status"
data-tooltip="Contentful preview panel is open — forces live updates regardless of the global toggle"
>Preview panel</span
>
<span class="control-panel__row-value"
<span class="control-panel__row-value" data-testid="preview-panel-status"
>{{ liveUpdatesService.previewPanelVisible() ? 'Open' : 'Closed' }}</span
>
<span></span>
<button
class="btn btn--sm btn--secondary"
data-testid="simulate-preview-panel-button"
(click)="liveUpdatesService.togglePreviewPanel()"
>
{{ liveUpdatesService.previewPanelVisible() ? 'Close Preview Panel' : 'Open Preview Panel' }}
</button>

<span
class="control-panel__row-label"
Expand All @@ -85,11 +92,12 @@ <h2 class="control-panel__title">SDK state</h2>

<span
class="control-panel__row-label"
data-testid="selected-optimizations-count"
data-tooltip="Number of selected optimization variants currently applied"
>Active optimizations</span
>
<span class="control-panel__row-value">{{ optimizationCount() }}</span>
<span class="control-panel__row-value" data-testid="selected-optimizations-count"
>{{ optimizationCount() }}</span
>
<span></span>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,19 @@ export class ControlPanel {
protected readonly optimizationCount = computed(
() => this.optimization.selectedOptimizations()?.length ?? 0,
)
protected readonly booleanFlag = fromSdkState<unknown>(
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.
Comment thread
nalchevanidze marked this conversation as resolved.
protected readonly booleanFlag = fromSdkState(() =>
this.consent() === true ? this.optimization.sdk.states.flag('boolean') : undefined,
)

protected toggleConsent(): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@let scenario = clickScenario();

<section [attr.data-testid]="'entry-card-' + (resolved()?.baselineId ?? '')">
<section [attr.data-testid]="'entry-card-' + effectiveTestId()">
@if (scenario === 'ancestor' && !manualTracking()) {
<div data-ctfl-clickable="true" data-testid="entry-click-ancestor-wrapper">
<ng-container [ngTemplateOutlet]="entryContent" />
Expand All @@ -14,7 +14,8 @@
@if (resolved(); as r) {
<div
class="entry-card"
[attr.data-testid]="'content-' + r.baselineId"
[attr.data-testid]="'content-' + effectiveTestId()"
[attr.data-test-entry-id]="r.entryId"
[attr.data-ctfl-entry-id]="manualTracking() ? null : r.entryId"
[attr.data-ctfl-baseline-id]="manualTracking() ? null : r.baselineId"
[attr.data-ctfl-optimization-id]="manualTracking() ? null : (r.optimizationId ?? null)"
Expand Down Expand Up @@ -47,7 +48,7 @@
</div>

<div
[attr.data-testid]="'entry-text-' + r.baselineId"
[attr.data-testid]="'entry-text-' + effectiveTestId()"
[attr.aria-label]="'Entry: ' + r.entryId"
>
@if (richTextHtml(); as html) {
Expand Down
Loading
Loading