Skip to content
Draft
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions packages/universal/api-schemas/src/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './ExperienceResponse'
export * from './optimization'
export * from './profile'
export * from './ResponseEnvelope'
export * from './sourceMap'
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from '@rstest/core'
import { SourceMap } from './SourceMap'

const VALID_SOURCE_MAP = {
variants: [
{ type: 'personalization', id: 'default' },
{ type: 'personalization', id: 'variant-a' },
],
layers: [
{ kind: 'Experience', id: 'exp-id', variants: [1] },
{ kind: 'Fragment', id: 'frag-id' },
],
nodes: {
'node-1': { layers: [0], scope: 0 },
'node-2': { layers: [1, 0], scope: 1 },
},
}

describe('SourceMap schema', () => {
it('accepts a valid sourceMap', () => {
const result = SourceMap.safeParse(VALID_SOURCE_MAP)

expect(result.success).toBe(true)
})

it('accepts layers without variants', () => {
const result = SourceMap.safeParse({
...VALID_SOURCE_MAP,
layers: [{ kind: 'Fragment', id: 'frag-id' }],
})

expect(result.success).toBe(true)
})

it('rejects missing nodes map', () => {
const { nodes: _removed, ...withoutNodes } = VALID_SOURCE_MAP
const result = SourceMap.safeParse(withoutNodes)

expect(result.success).toBe(false)
})

it('rejects node missing scope', () => {
const result = SourceMap.safeParse({
...VALID_SOURCE_MAP,
nodes: { 'node-1': { layers: [0] } },
})

expect(result.success).toBe(false)
})

it('accepts an empty variants array', () => {
const result = SourceMap.safeParse({ ...VALID_SOURCE_MAP, variants: [] })

expect(result.success).toBe(true)
})
})
117 changes: 117 additions & 0 deletions packages/universal/api-schemas/src/experience/sourceMap/SourceMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import * as z from 'zod/mini'

/**
* A single variant entry from the XDA `extensions.sourceMap.variants` array.
*
* @public
*/
export const SourceMapVariant = z.object({
/**
* Variant category, e.g. `'personalization'`.
*/
type: z.string(),
/**
* Variant identifier, e.g. `'default'` or a variant sys.id.
*/
id: z.string(),
})

/**
* TypeScript type inferred from {@link SourceMapVariant}.
*
* @public
*/
export type SourceMapVariant = z.infer<typeof SourceMapVariant>

/**
* A structural layer from the XDA `extensions.sourceMap.layers` array.
*
* @public
*/
export const SourceMapLayer = z.object({
/**
* Structural kind of the layer.
*
* @remarks
* Possible values include `'Experience'`, `'Fragment'`,
* `'InlineFragment'`, and `'InlineComponent'`.
*/
kind: z.string(),
/**
* Contentful sys.id of the Experience or Fragment entry this layer
* represents.
*/
id: z.string(),
/**
* Optional indices into `SourceMap.variants[]` that apply to this layer.
*
* @remarks
* Present only on layers that correspond to an optimization target.
*/
variants: z.optional(z.array(z.number())),
})

/**
* TypeScript type inferred from {@link SourceMapLayer}.
*
* @public
*/
export type SourceMapLayer = z.infer<typeof SourceMapLayer>

/**
* Metadata for a single rendered node from the XDA
* `extensions.sourceMap.nodes` map.
*
* @public
*/
export const SourceMapNode = z.object({
/**
* Leaf-to-root indices into `SourceMap.layers[]` for this node.
*/
layers: z.array(z.number()),
/**
* Index of the nearest ancestor Fragment or Experience layer in
* `SourceMap.layers[]`.
*/
scope: z.number(),
})

/**
* TypeScript type inferred from {@link SourceMapNode}.
*
* @public
*/
export type SourceMapNode = z.infer<typeof SourceMapNode>

/**
* Zod schema for the `extensions.sourceMap` object returned in XDA
* responses.
*
* @remarks
* The sourceMap provides structural context for each rendered node,
* enabling the SDK to resolve entity identity and variant selection
* without additional server round-trips.
*
* @public
*/
export const SourceMap = z.object({
/**
* Flat list of variant entries referenced by layers.
*/
variants: z.array(SourceMapVariant),
/**
* Flat list of structural layers ordered leaf-to-root.
*/
layers: z.array(SourceMapLayer),
/**
* Map from rendered node ID to node metadata.
*/
nodes: z.record(z.string(), SourceMapNode),
})

/**
* TypeScript type inferred from {@link SourceMap}.
*
* @public
*/
export type SourceMap = z.infer<typeof SourceMap>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SourceMap'
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ import * as z from 'zod/mini'
import { ViewEvent } from '../../experience/event'
import { ClickEvent } from './ClickEvent'
import { HoverEvent } from './HoverEvent'
import { NodeViewEvent } from './NodeViewEvent'

/**
* Zod schema describing an Insights event.
*
* @remarks
* Insights events include {@link ViewEvent},
* {@link ClickEvent}, and {@link HoverEvent}.
* {@link ClickEvent}, {@link HoverEvent}, and {@link NodeViewEvent}.
*
* @public
*/
export const InsightsEvent = z.discriminatedUnion('type', [ViewEvent, ClickEvent, HoverEvent])
export const InsightsEvent = z.discriminatedUnion('type', [
ViewEvent,
ClickEvent,
HoverEvent,
NodeViewEvent,
])

/**
* TypeScript type inferred from {@link InsightsEvent}.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from '@rstest/core'
import { InsightsEvent } from './InsightsEvent'
import { NodeViewEvent } from './NodeViewEvent'

const BASE_UNIVERSAL = {
channel: 'web' as const,
context: {
campaign: {},
gdpr: { isConsentGiven: true },
library: { name: 'test', version: '0.0.0' },
locale: 'en-US',
},
messageId: 'msg-1',
originalTimestamp: '2024-01-01T00:00:00.000Z',
sentAt: '2024-01-01T00:00:00.000Z',
timestamp: '2024-01-01T00:00:00.000Z',
}

const VALID_NODE_VIEW = {
...BASE_UNIVERSAL,
anonymousId: 'anon-id',
type: 'exo_view' as const,
entityId: 'exp-sys-id',
entityKind: 'Experience' as const,
variant: 'variant-a',
optimizationId: 'opt-id',
viewId: 'view-uuid',
viewDurationMs: 1500,
}

describe('NodeViewEvent schema', () => {
it('accepts a valid payload', () => {
const result = NodeViewEvent.safeParse(VALID_NODE_VIEW)

expect(result.success).toBe(true)
})

it('accepts all valid entityKind values', () => {
const kinds = ['Experience', 'Fragment', 'InlineFragment', 'InlineComponent'] as const

for (const entityKind of kinds) {
const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, entityKind })

expect(result.success, `entityKind=${entityKind}`).toBe(true)
}
})

it('rejects an unknown entityKind', () => {
const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, entityKind: 'Unknown' })

expect(result.success).toBe(false)
})

it('rejects a missing required field', () => {
const { entityId: _removed, ...withoutEntityId } = VALID_NODE_VIEW
const result = NodeViewEvent.safeParse(withoutEntityId)

expect(result.success).toBe(false)
})

it('rejects a non-number viewDurationMs', () => {
const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, viewDurationMs: 'long' })

expect(result.success).toBe(false)
})
})

describe('InsightsEvent discriminated union', () => {
it('discriminates exo_view correctly', () => {
const result = InsightsEvent.safeParse(VALID_NODE_VIEW)

expect(result.success).toBe(true)
if (result.success) {
expect(result.data.type).toBe('exo_view')
}
})
})
Loading