Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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