Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
750d49f
NT-1769: node-esr implementation
nalchevanidze Jan 5, 2026
522df00
basi setup for e2e tests
nalchevanidze Jan 5, 2026
2f0b003
update app
nalchevanidze Jan 5, 2026
5f6439c
fix scripts
nalchevanidze Jan 5, 2026
7b77442
linter
nalchevanidze Jan 5, 2026
672ab01
client id
nalchevanidze Jan 5, 2026
84e85e5
client id
nalchevanidze Jan 5, 2026
39637ee
update tests
nalchevanidze Jan 5, 2026
b937245
update setup
nalchevanidze Jan 5, 2026
9c57cb5
update consts
nalchevanidze Jan 5, 2026
a8d7c7e
CUSTOM_PROFILE_ID
nalchevanidze Jan 5, 2026
5648e44
update
nalchevanidze Jan 5, 2026
92e6275
update events
nalchevanidze Jan 5, 2026
4cc7e0c
use anonymousId from backen.
nalchevanidze Jan 5, 2026
6e9124b
remove event stream
nalchevanidze Jan 5, 2026
83a6f05
scripts
nalchevanidze Jan 5, 2026
39659e4
remove client id
nalchevanidze Jan 5, 2026
8f09608
format
nalchevanidze Jan 6, 2026
9d06e80
logred in and not loged in cases
nalchevanidze Jan 6, 2026
22da26b
add additional cases
nalchevanidze Jan 6, 2026
95a4e9e
format
nalchevanidze Jan 6, 2026
68cd82b
update rate limiter
nalchevanidze Jan 6, 2026
28ec3e2
update pipelines
nalchevanidze Jan 6, 2026
8950584
update job scripts
nalchevanidze Jan 6, 2026
c3115d1
update env
nalchevanidze Jan 6, 2026
9b2625d
update limiter
nalchevanidze Jan 6, 2026
5eb2d9d
limiter
nalchevanidze Jan 6, 2026
f43aefa
Merge branch 'main' into NT-1769
nalchevanidze Jan 6, 2026
cca475e
esr app
nalchevanidze Jan 6, 2026
79bbf7f
mocks
nalchevanidze Jan 6, 2026
3413cd9
increase limiter
nalchevanidze Jan 6, 2026
efb3a7c
let it pass
nalchevanidze Jan 6, 2026
b7b1040
update e2e tests
nalchevanidze Jan 6, 2026
0f8fe4e
update pipeline
nalchevanidze Jan 6, 2026
2170407
format
nalchevanidze Jan 6, 2026
3252f73
update script
nalchevanidze Jan 6, 2026
262e7dc
update scripts
nalchevanidze Jan 6, 2026
74c1607
update app
nalchevanidze Jan 6, 2026
a6269c7
remove unused template
nalchevanidze Jan 6, 2026
e5b94fe
format
nalchevanidze Jan 6, 2026
c1f9447
separate sdk initiaziation
nalchevanidze Jan 6, 2026
3cd9c5e
white space
nalchevanidze Jan 6, 2026
1acb135
respond
nalchevanidze Jan 6, 2026
e79b465
format
nalchevanidze Jan 6, 2026
521a5e4
update ids
nalchevanidze Jan 7, 2026
801fe9a
remove unecessary -ssr from run
nalchevanidze Jan 7, 2026
bea91be
update e2e tests
nalchevanidze Jan 7, 2026
1792f9a
update cookies
nalchevanidze Jan 7, 2026
9daf703
utils
nalchevanidze Jan 7, 2026
1a76c46
no path
nalchevanidze Jan 7, 2026
bbdccc7
set cookies
nalchevanidze Jan 7, 2026
f82b1d0
format
nalchevanidze Jan 7, 2026
a7f39b5
separate test cases
nalchevanidze Jan 7, 2026
b67f5fe
fix headers
nalchevanidze Jan 7, 2026
4bc7d7c
remove domain
nalchevanidze Jan 7, 2026
d004251
update path for artifacts
nalchevanidze Jan 7, 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
57 changes: 52 additions & 5 deletions .github/workflows/main-pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,8 @@ jobs:

- run: pnpm test:unit

e2e-node:
name: E2E Node 🖥️
e2e-node-ssr:
name: E2E Node SSR 🖥️
runs-on: ubuntu-latest
timeout-minutes: 15
needs: setup
Expand Down Expand Up @@ -240,19 +240,66 @@ jobs:

- run: pnpm i --prefer-offline --frozen-lockfile

- run: pnpm --filter @implementation/node exec playwright install --with-deps
- run: pnpm --filter @implementation/node-ssr exec playwright install --with-deps

- run: pnpm --filter @implementation/node test:e2e
- run: pnpm --filter @implementation/node-ssr test:e2e

- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: ci-results-node
name: ci-results-node-ssr
path: |
./implementations/node-ssr/playwright-report/
./implementations/node-ssr/test-results/
retention-days: 1

e2e-node-esr:
name: E2E Node ESR 🖥️
runs-on: ubuntu-latest
timeout-minutes: 15
needs: setup
steps:
- uses: actions/checkout@v6

- run: |
echo "DOTENV_CONFIG_QUIET=true" >>implementations/node-esr/.env
echo "VITE_NINETAILED_CLIENT_ID=${{secrets.NINETAILED_CLIENT_ID}}" >>implementations/node-esr/.env
echo "VITE_NINETAILED_ENVIRONMENT=${{secrets.NINETAILED_ENVIRONMENT}}" >>implementations/node-esr/.env
echo "VITE_EXPERIENCE_API_BASE_URL=http://localhost:8000/experience/" >>implementations/node-esr/.env
echo "VITE_INSIGHTS_API_BASE_URL=http://localhost:8000/insights/" >>implementations/node-esr/.env
echo "VITE_CONTENTFUL_TOKEN=${{secrets.CONTENTFUL_TOKEN}}" >>implementations/node-esr/.env
echo "VITE_CONTENTFUL_PREVIEW_TOKEN=${{secrets.CONTENTFUL_PREVIEW_TOKEN}}" >>implementations/node-esr/.env
echo "VITE_CONTENTFUL_ENVIRONMENT=${{secrets.CONTENTFUL_ENVIRONMENT}}" >>implementations/node-esr/.env
echo "VITE_CONTENTFUL_SPACE_ID=${{secrets.CONTENTFUL_SPACE_ID}}" >>implementations/node-esr/.env
echo "VITE_CONTENTFUL_CDA_HOST=localhost:8000" >>implementations/node-esr/.env
echo "VITE_CONTENTFUL_BASE_PATH=contentful" >>implementations/node-esr/.env

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

- uses: actions/cache@v5
with:
path: node_modules
key: ${{ runner.os }}-node-modules-pnpm-${{ hashFiles('pnpm-lock.yaml') }}

- run: pnpm i --prefer-offline --frozen-lockfile

- run: pnpm --filter @implementation/node-esr exec playwright install --with-deps

- run: pnpm --filter @implementation/node-esr test:e2e

- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: ci-results-node-esr
path: |
./implementations/node-esr/playwright-report/
./implementations/node-esr/test-results/
retention-days: 1

e2e-web:
name: E2E Web Vanilla 🖥️
runs-on: ubuntu-latest
Expand Down
127 changes: 127 additions & 0 deletions implementations/node-esr/assets/script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/* --- Standard Rendering Code --- */

const contentfulClient = contentful.createClient(CONFIG.contentful)
const optimization = new Optimization({
...CONFIG.optimization,
autoTrackEntryViews: true,
app: { name: document.title, version: '0.0.0' },
})

optimization.personalization.page()

function isRichText(field) {
return field && typeof field === 'object' && field.content !== undefined
}

// Barebones "Rich Text" renderer
// Full renderer must be customer-supplied, or rendered via ctfl Rich Text libs where possible
function simpleRenderRichText(field, parent) {
if (!field || typeof field !== 'object') return
if (!parent || !(parent instanceof HTMLElement)) return

if (field.nodeType === 'document') {
field.content.forEach((content) => simpleRenderRichText(content, parent))
} else if (field.nodeType === 'paragraph') {
const p = document.createElement('p')
field.content.forEach((content) => simpleRenderRichText(content, p))
parent.appendChild(p)
} else if (field.nodeType === 'text') {
const span = document.createElement('span')
span.innerText = field.value
parent.appendChild(span)
} else if (field.nodeType === 'embedded-entry-inline') {
const span = document.createElement('span')

// This is how we can inject MergeTag data into Rich Text
span.innerText = optimization.personalization.getMergeTagValue(field.data.target)

parent.appendChild(span)
} else {
const span = document.createElement('span')
span.innerText = '[unknown rich text fragment]'
parent.appendChild(span)
}
}

// Render personalized entries
async function renderPersonalizedEntry(rawEntry, element, autoObserve = true) {
const { entry, personalization } = optimization.personalization.personalizeEntry(rawEntry)

if (isRichText(entry.fields?.text)) {
const div = document.createElement('div')
simpleRenderRichText(entry.fields.text, div)
element.replaceChildren(div)
} else {
const p = document.createElement('p')
p.innerText = entry.fields?.text
element.replaceChildren(p)
}

if (entry.fields?.nested) {
const div = document.createElement('div')
div.className = 'nested'
entry.fields.nested.forEach((nestedEntry) =>
renderPersonalizedEntry(nestedEntry, div, autoObserve),
)
element.appendChild(div)
}

// NOTE: Elements that are not auto-observed may still be auto-tracked (see below)
if (autoObserve) {
// The `data-ctfl - entry - id` data attribute is required for auto-observing
element.dataset.ctflEntryId = entry.sys?.id

// Other standard auto-observing attributes are optional
if (personalization) {
element.dataset.ctflPersonalizationId = personalization?.experienceId
element.dataset.ctflSticky = personalization?.sticky
element.dataset.ctflVariantIndex = personalization?.variantIndex
}
}

return [entry, personalization]
}

// Get the personalized entry to render
async function addPersonalizedEntry(entryId, element, autoObserve = true) {
try {
const entry = await contentfulClient.getEntry(entryId, { include: 10 })
return renderPersonalizedEntry(entry, element, autoObserve)
} catch (error) {
console.warn(`Entry "${entryId}" could not be found in the current space`)
return []
}
}

// Manually observe view elements for auto-tracking
async function manuallyObserveEntryElement(element) {
const [entry, personalization] = await addPersonalizedEntry(
element.dataset.entryId,
element,
false,
)

optimization.untrackEntryViewForElement(element)

// Manually observe the element for auto-tracking (does not use `data - ctfl -* ` attributes)
optimization.trackEntryViewForElement(element, {
data: {
// The `entryId` property is required for auto-tracking
entryId: entry.sys.id,
personalizationId: personalization?.experienceId,
sticky: personalization?.sticky,
variantIndex: personalization?.variantIndex,
},
})
}

// Subscribe to profile state, find entries in the markup, and render them
optimization.states.profile.subscribe((profile) => {
if (!profile) return

document
.querySelectorAll('[data-ctfl-entry-id]')
.forEach((element) => addPersonalizedEntry(element.dataset.ctflEntryId, element))

document.querySelectorAll('[data-entry-id]').forEach(manuallyObserveEntryElement)
})
94 changes: 94 additions & 0 deletions implementations/node-esr/assets/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
* {
box-sizing: border-box;
}
html,
textarea,
input,
button {
font-family:
-apple-system,
BlinkMacSystemFont,
avenir next,
avenir,
segoe ui,
helvetica neue,
Adwaita Sans,
Cantarell,
Ubuntu,
roboto,
noto,
helvetica,
arial,
sans-serif;
}
h1,
main {
max-width: 1040px;
margin-inline: auto;
}
main {
display: grid;
grid-gap: 2rem;
grid-template-rows: auto 1fr;
grid-template-columns: 1fr auto;
}
section:first-of-type {
grid-column: 1 / span 2;
}
ol {
margin: 0;
}
summary {
cursor: pointer;
}
button {
cursor: pointer;

&:not([type]) {
appearance: none;
background: transparent;
border: 0;
padding: 0;
font-family: inherit;
font-size: inherit;
text-decoration: underline;

&:hover,
&:active {
color: blue;
}
}
}
#event-stream-panel {
position: sticky;
top: 0;
margin-block-start: -1px;
padding-block-start: 1px;
}
#auto-observed,
#manually-observed {
display: grid;
grid-gap: 2rem;
grid-auto-rows: minmax(75dvh, auto);
margin-block-end: 2rem;

> div {
display: grid;
grid-gap: inherit;
grid-auto-rows: inherit;
width: 100%;
height: 100%;
padding: 2rem;
background: #eee;
overflow: visible;
}

div.nested {
display: grid;
grid-gap: inherit;
grid-auto-rows: inherit;
padding: 1rem;
border: 1px solid #ccc;
overflow: visible;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-core'
import { expect, test } from '@playwright/test'
import { getAnonymousIdFromCookie, getAnonymousIdFromStorage } from './utils'

const CUSTOM_PROFILE_ID = 'custom-profile-id'

test.describe('identified user: cookie', () => {
test.beforeEach(async ({ page, context }) => {
// user is already identified with a custom profile id
await context.addCookies([
{
name: ANONYMOUS_ID_COOKIE,
value: CUSTOM_PROFILE_ID,
domain: 'localhost',
path: '/',
sameSite: 'Lax', // good default for same-site apps
},
])
await page.goto(`/user/someone`)
await page.waitForLoadState('domcontentloaded')
})

test('should preserve custom profile id in cookie', async ({ context }) => {
const cookieId = await getAnonymousIdFromCookie(context)
expect(cookieId).toBeDefined()
expect(cookieId).toEqual(CUSTOM_PROFILE_ID)
})

test('should sync profile id between cookie and localStorage', async ({ context }) => {
const cookieId = await getAnonymousIdFromCookie(context)
const storedId = await getAnonymousIdFromStorage(context)

expect(storedId).toBeDefined()
expect(storedId).toEqual(cookieId)
})

test('displays common variants', async ({ page }) => {
await expect(
page.getByText(
'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.',
),
).toBeVisible()

await expect(
page.getByText('This is a variant content entry for visitors from Europe.'),
).toBeVisible()

await expect(
page.getByText('This is a variant content entry for visitors using a desktop browser.'),
).toBeVisible()
})

test('displays identified user variants', async ({ page }) => {
await expect(page.getByText('This is a level 0 nested variant entry.')).toBeVisible()

await expect(page.getByText('This is a level 1 nested variant entry.')).toBeVisible()

await expect(page.getByText('This is a level 2 nested variant entry.')).toBeVisible()

await expect(
page.getByText('This is a variant content entry for return visitors.'),
).toBeVisible()

await expect(
page.getByText('This is a variant content entry for an A/B/C experiment: B'),
).toBeVisible()

await expect(
page.getByText('This is a variant content entry for visitors with a custom event.'),
).toBeVisible()

await expect(
page.getByText('This is a variant content entry for identified users.'),
).toBeVisible()
})
})
Loading