diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 07cc710..1fbc7e5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,35 @@ -// Placeholder for core package -// Will be implemented in Phase 0 +/** + * Experience SDK - Core Package + * + * A lightweight, explainable, plugin-based client-side experience runtime + * built on @lytics/sdk-kit. + */ -export {}; +// Export all types +export type { + Experience, + TargetingRules, + UrlRule, + FrequencyRule, + FrequencyConfig, + ExperienceContent, + BannerContent, + ModalContent, + TooltipContent, + ModalAction, + Context, + UserContext, + Decision, + TraceStep, + DecisionMetadata, + ExperienceConfig, + RuntimeState, +} from './types'; + +// Export runtime class and functions +export { + ExperienceRuntime, + buildContext, + evaluateExperience, + evaluateUrlRule, +} from './runtime'; diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts new file mode 100644 index 0000000..90f96f6 --- /dev/null +++ b/packages/core/src/runtime.test.ts @@ -0,0 +1,468 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ExperienceRuntime, evaluateUrlRule } from './runtime'; + +describe('ExperienceRuntime', () => { + let runtime: ExperienceRuntime; + + beforeEach(() => { + runtime = new ExperienceRuntime({ debug: false }); + }); + + describe('constructor', () => { + it('should create runtime with default config', () => { + const runtime = new ExperienceRuntime(); + + expect(runtime).toBeDefined(); + expect(runtime.getState().initialized).toBe(false); + }); + + it('should accept initial config', () => { + const runtime = new ExperienceRuntime({ debug: true }); + + expect(runtime).toBeDefined(); + }); + }); + + describe('init()', () => { + it('should initialize runtime', async () => { + await runtime.init(); + + expect(runtime.getState().initialized).toBe(true); + }); + + it('should emit ready event', async () => { + const readyHandler = vi.fn(); + runtime.on('experiences:ready', readyHandler); + + await runtime.init(); + + expect(readyHandler).toHaveBeenCalledOnce(); + }); + + it('should not re-initialize if already initialized', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await runtime.init(); + await runtime.init(); + + expect(consoleWarnSpy).toHaveBeenCalledWith('[experiences] Already initialized'); + consoleWarnSpy.mockRestore(); + }); + + it('should accept config on init', async () => { + await runtime.init({ debug: true }); + + expect(runtime.getState().initialized).toBe(true); + }); + }); + + describe('register()', () => { + it('should register an experience', () => { + runtime.register('welcome', { + type: 'banner', + targeting: { + url: { contains: '/' }, + }, + content: { + title: 'Welcome!', + message: 'Welcome to our site', + }, + }); + + const state = runtime.getState(); + expect(state.experiences.has('welcome')).toBe(true); + }); + + it('should emit registered event', () => { + const registeredHandler = vi.fn(); + runtime.on('experiences:registered', registeredHandler); + + runtime.register('test', { + type: 'banner', + targeting: {}, + content: { title: 'Test', message: 'Test message' }, + }); + + expect(registeredHandler).toHaveBeenCalledOnce(); + expect(registeredHandler).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test', + experience: expect.objectContaining({ type: 'banner' }), + }) + ); + }); + + it('should allow multiple experiences', () => { + runtime.register('exp1', { + type: 'banner', + targeting: {}, + content: { title: 'Exp 1', message: 'Message 1' }, + }); + + runtime.register('exp2', { + type: 'banner', + targeting: {}, + content: { title: 'Exp 2', message: 'Message 2' }, + }); + + const state = runtime.getState(); + expect(state.experiences.size).toBe(2); + }); + }); + + describe('evaluate()', () => { + beforeEach(() => { + runtime.register('test', { + type: 'banner', + targeting: { + url: { contains: '/products' }, + }, + content: { title: 'Test', message: 'Test message' }, + }); + }); + + it('should return decision with matched experience', () => { + const decision = runtime.evaluate({ + url: 'https://example.com/products/123', + }); + + expect(decision.show).toBe(true); + expect(decision.experienceId).toBe('test'); + expect(decision.reasons).toContain('URL matches targeting rule'); + }); + + it('should return decision with no match', () => { + const decision = runtime.evaluate({ + url: 'https://example.com/about', + }); + + expect(decision.show).toBe(false); + expect(decision.experienceId).toBeUndefined(); + expect(decision.reasons).toContain('URL does not match targeting rule'); + }); + + it('should include trace steps', () => { + const decision = runtime.evaluate({ + url: 'https://example.com/products', + }); + + expect(decision.trace).toHaveLength(1); + expect(decision.trace[0]).toMatchObject({ + step: 'evaluate-url-rule', + passed: true, + }); + }); + + it('should include context', () => { + const decision = runtime.evaluate({ + url: 'https://example.com/products', + user: { id: '123' }, + }); + + expect(decision.context).toMatchObject({ + url: 'https://example.com/products', + user: { id: '123' }, + }); + }); + + it('should include metadata', () => { + const decision = runtime.evaluate(); + + expect(decision.metadata).toMatchObject({ + evaluatedAt: expect.any(Number), + totalDuration: expect.any(Number), + experiencesEvaluated: 1, + }); + }); + + it('should emit evaluated event', () => { + const evaluatedHandler = vi.fn(); + runtime.on('experiences:evaluated', evaluatedHandler); + + runtime.evaluate(); + + expect(evaluatedHandler).toHaveBeenCalledOnce(); + }); + + it('should store decision history', () => { + runtime.evaluate(); + runtime.evaluate(); + + const state = runtime.getState(); + expect(state.decisions).toHaveLength(2); + }); + + it('should use current URL when none provided', () => { + const decision = runtime.evaluate(); + + expect(decision.context.url).toBeDefined(); + }); + + it('should match first experience only', () => { + runtime.register('test2', { + type: 'banner', + targeting: { + url: { contains: '/products' }, + }, + content: { title: 'Test 2', message: 'Test 2 message' }, + }); + + const decision = runtime.evaluate({ + url: 'https://example.com/products', + }); + + expect(decision.show).toBe(true); + expect(decision.experienceId).toBe('test'); + }); + }); + + describe('explain()', () => { + it('should explain specific experience', () => { + runtime.register('test', { + type: 'banner', + targeting: { + url: { contains: '/test' }, + }, + content: { title: 'Test', message: 'Test message' }, + }); + + const explanation = runtime.explain('test'); + + expect(explanation).not.toBeNull(); + expect(explanation?.experienceId).toBe('test'); + expect(explanation?.reasons).toBeDefined(); + }); + + it('should return null for non-existent experience', () => { + const explanation = runtime.explain('non-existent'); + + expect(explanation).toBeNull(); + }); + }); + + describe('getState()', () => { + it('should return runtime state', () => { + const state = runtime.getState(); + + expect(state).toMatchObject({ + initialized: false, + experiences: expect.any(Map), + decisions: expect.any(Array), + config: expect.any(Object), + }); + }); + + it('should reflect registered experiences', () => { + runtime.register('test', { + type: 'banner', + targeting: {}, + content: { title: 'Test', message: 'Test message' }, + }); + + const state = runtime.getState(); + expect(state.experiences.size).toBe(1); + expect(state.experiences.get('test')).toBeDefined(); + }); + }); + + describe('on()', () => { + it('should subscribe to events', () => { + const handler = vi.fn(); + const unsubscribe = runtime.on('experiences:ready', handler); + + expect(unsubscribe).toBeTypeOf('function'); + }); + + it('should allow unsubscribe', async () => { + const handler = vi.fn(); + const unsubscribe = runtime.on('experiences:ready', handler); + + unsubscribe(); + await runtime.init(); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('destroy()', () => { + it('should clean up runtime', async () => { + await runtime.init(); + runtime.register('test', { + type: 'banner', + targeting: {}, + content: { title: 'Test', message: 'Test message' }, + }); + + await runtime.destroy(); + + const state = runtime.getState(); + expect(state.initialized).toBe(false); + expect(state.experiences.size).toBe(0); + expect(state.decisions).toHaveLength(0); + }); + }); + + describe('URL targeting', () => { + describe('equals rule', () => { + beforeEach(() => { + runtime.register('exact', { + type: 'banner', + targeting: { + url: { equals: 'https://example.com/exact' }, + }, + content: { title: 'Exact', message: 'Exact match' }, + }); + }); + + it('should match exact URL', () => { + const decision = runtime.evaluate({ + url: 'https://example.com/exact', + }); + + expect(decision.show).toBe(true); + }); + + it('should not match different URL', () => { + const decision = runtime.evaluate({ + url: 'https://example.com/other', + }); + + expect(decision.show).toBe(false); + }); + }); + + describe('contains rule', () => { + beforeEach(() => { + runtime.register('contains', { + type: 'banner', + targeting: { + url: { contains: '/products' }, + }, + content: { title: 'Products', message: 'Products page' }, + }); + }); + + it('should match URL containing substring', () => { + const decision = runtime.evaluate({ + url: 'https://example.com/products/123', + }); + + expect(decision.show).toBe(true); + }); + + it('should not match URL without substring', () => { + const decision = runtime.evaluate({ + url: 'https://example.com/about', + }); + + expect(decision.show).toBe(false); + }); + }); + + describe('matches (regex) rule', () => { + beforeEach(() => { + runtime.register('regex', { + type: 'banner', + targeting: { + url: { matches: /\/product\/\d+/ }, + }, + content: { title: 'Product', message: 'Product page' }, + }); + }); + + it('should match URL with regex', () => { + const decision = runtime.evaluate({ + url: 'https://example.com/product/123', + }); + + expect(decision.show).toBe(true); + }); + + it('should not match URL without regex pattern', () => { + const decision = runtime.evaluate({ + url: 'https://example.com/product/abc', + }); + + expect(decision.show).toBe(false); + }); + }); + + describe('empty rule', () => { + it('should match when no rule properties specified', () => { + const result = evaluateUrlRule({}, 'https://example.com/any-page'); + + expect(result).toBe(true); + }); + }); + }); + + describe('evaluateUrlRule', () => { + it('should match with equals rule', () => { + expect(evaluateUrlRule({ equals: 'https://example.com' }, 'https://example.com')).toBe( + true + ); + expect(evaluateUrlRule({ equals: 'https://example.com' }, 'https://other.com')).toBe(false); + }); + + it('should match with contains rule', () => { + expect(evaluateUrlRule({ contains: '/products' }, 'https://example.com/products')).toBe( + true + ); + expect(evaluateUrlRule({ contains: '/products' }, 'https://example.com/about')).toBe(false); + }); + + it('should match with regex rule', () => { + expect(evaluateUrlRule({ matches: /\/product\/\d+/ }, 'https://example.com/product/123')).toBe( + true + ); + expect(evaluateUrlRule({ matches: /\/product\/\d+/ }, 'https://example.com/product/abc')).toBe( + false + ); + }); + + it('should return true for empty rule', () => { + expect(evaluateUrlRule({}, 'https://example.com')).toBe(true); + }); + + it('should handle empty URL', () => { + expect(evaluateUrlRule({ equals: '' }, '')).toBe(true); + expect(evaluateUrlRule({ contains: 'test' }, '')).toBe(false); + }); + }); + + describe('frequency targeting', () => { + it('should track frequency rule in trace', () => { + runtime.register('limited', { + type: 'banner', + targeting: {}, + frequency: { + per: 'session', + max: 1, + }, + content: { title: 'Limited', message: 'Limited banner' }, + }); + + const decision = runtime.evaluate(); + + expect(decision.trace.some((step) => step.step === 'check-frequency-rule')).toBe(true); + expect(decision.reasons.some((r) => r.includes('Frequency rule'))).toBe(true); + }); + }); + + describe('no targeting', () => { + it('should always match when no targeting rules', () => { + runtime.register('always', { + type: 'banner', + targeting: {}, + content: { title: 'Always', message: 'Always shown' }, + }); + + const decision = runtime.evaluate({ + url: 'https://example.com/any-page', + }); + + expect(decision.show).toBe(true); + }); + }); +}); + diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts new file mode 100644 index 0000000..6b4a827 --- /dev/null +++ b/packages/core/src/runtime.ts @@ -0,0 +1,266 @@ +import { SDK } from '@lytics/sdk-kit'; +import type { + Context, + Decision, + Experience, + ExperienceConfig, + RuntimeState, + TraceStep, + UrlRule, +} from './types'; + +/** + * Experience Runtime + * + * Core class that manages experience registration and evaluation. + * Built on @lytics/sdk-kit for plugin system and lifecycle management. + * + * Design principles: + * - Pure functions for evaluation logic (easy to test) + * - Event-driven architecture (extensible via plugins) + * - Explainability-first (every decision has reasons) + */ +export class ExperienceRuntime { + private sdk: SDK; + private experiences: Map = new Map(); + private decisions: Decision[] = []; + private initialized = false; + + constructor(config: ExperienceConfig = {}) { + // Create SDK instance + this.sdk = new SDK({ + name: 'experience-sdk', + ...config, + }); + } + + /** + * Initialize the runtime + */ + async init(config?: ExperienceConfig): Promise { + if (this.initialized) { + console.warn('[experiences] Already initialized'); + return; + } + + if (config) { + // Merge config if provided + Object.entries(config).forEach(([key, value]) => { + this.sdk.set(key, value); + }); + } + + // Initialize SDK (will init all plugins) + await this.sdk.init(); + + this.initialized = true; + + // Emit ready event + this.sdk.emit('experiences:ready'); + } + + /** + * Register an experience + */ + register(id: string, experience: Omit): void { + const exp: Experience = { id, ...experience }; + this.experiences.set(id, exp); + + this.sdk.emit('experiences:registered', { id, experience: exp }); + } + + /** + * Evaluate experiences against context + * Returns decision with explainability + */ + evaluate(context?: Partial): Decision { + const startTime = Date.now(); + const evalContext = buildContext(context); + + // Find matching experience + let matchedExperience: Experience | undefined; + const allReasons: string[] = []; + const allTrace: TraceStep[] = []; + + for (const [, experience] of this.experiences) { + const result = evaluateExperience(experience, evalContext); + + allReasons.push(...result.reasons); + allTrace.push(...result.trace); + + if (result.matched) { + matchedExperience = experience; + break; // First match wins + } + } + + const decision: Decision = { + show: !!matchedExperience, + experienceId: matchedExperience?.id, + reasons: allReasons, + trace: allTrace, + context: evalContext, + metadata: { + evaluatedAt: Date.now(), + totalDuration: Date.now() - startTime, + experiencesEvaluated: this.experiences.size, + }, + }; + + // Store decision for inspection + this.decisions.push(decision); + + // Emit for plugins to react + this.sdk.emit('experiences:evaluated', decision); + + return decision; + } + + /** + * Explain a specific experience + */ + explain(experienceId: string): Decision | null { + const experience = this.experiences.get(experienceId); + if (!experience) { + return null; + } + + const context = buildContext(); + const result = evaluateExperience(experience, context); + + return { + show: result.matched, + experienceId, + reasons: result.reasons, + trace: result.trace, + context, + metadata: { + evaluatedAt: Date.now(), + totalDuration: 0, + experiencesEvaluated: 1, + }, + }; + } + + /** + * Get runtime state (for inspection) + */ + getState(): RuntimeState { + return { + initialized: this.initialized, + experiences: new Map(this.experiences), + decisions: [...this.decisions], + config: this.sdk.getAll(), + }; + } + + /** + * Event subscription (proxy to SDK) + */ + on(event: string, handler: (...args: any[]) => void): () => void { + return this.sdk.on(event, handler); + } + + /** + * Destroy runtime + */ + async destroy(): Promise { + await this.sdk.destroy(); + this.experiences.clear(); + this.decisions = []; + this.initialized = false; + } +} + +// Pure functions for evaluation logic (easy to test, no dependencies) + +/** + * Build evaluation context from partial input + * Pure function - no side effects + */ +export function buildContext(partial?: Partial): Context { + return { + url: partial?.url ?? (typeof window !== 'undefined' ? window.location.href : ''), + timestamp: Date.now(), + user: partial?.user, + custom: partial?.custom, + }; +} + +/** + * Evaluate an experience against context + * Pure function - returns reasons and trace + */ +export function evaluateExperience( + experience: Experience, + context: Context +): { matched: boolean; reasons: string[]; trace: TraceStep[] } { + const reasons: string[] = []; + const trace: TraceStep[] = []; + let matched = true; + + // Evaluate URL rule + if (experience.targeting.url) { + const urlStart = Date.now(); + const urlMatch = evaluateUrlRule(experience.targeting.url, context.url); + + trace.push({ + step: 'evaluate-url-rule', + timestamp: urlStart, + duration: Date.now() - urlStart, + input: { rule: experience.targeting.url, url: context.url }, + output: urlMatch, + passed: urlMatch, + }); + + if (urlMatch) { + reasons.push('URL matches targeting rule'); + } else { + reasons.push('URL does not match targeting rule'); + matched = false; + } + } + + // Evaluate frequency rule (will be checked by frequency plugin) + if (experience.frequency) { + const freqStart = Date.now(); + // Note: Actual frequency checking is done by the frequency plugin + // This just records that a frequency rule exists + trace.push({ + step: 'check-frequency-rule', + timestamp: freqStart, + duration: Date.now() - freqStart, + input: experience.frequency, + output: true, + passed: true, + }); + reasons.push('Frequency rule will be checked by frequency plugin'); + } + + return { matched, reasons, trace }; +} + +/** + * Evaluate URL targeting rule + * Pure function - deterministic output + */ +export function evaluateUrlRule(rule: UrlRule, url: string = ''): boolean { + // Check equals (exact match) + if (rule.equals !== undefined) { + return url === rule.equals; + } + + // Check contains (substring match) + if (rule.contains !== undefined) { + return url.includes(rule.contains); + } + + // Check matches (regex match) + if (rule.matches !== undefined) { + return rule.matches.test(url); + } + + // No rules specified = match all + return true; +} + diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json index 209f88a..8a34969 100644 --- a/packages/plugins/tsconfig.json +++ b/packages/plugins/tsconfig.json @@ -5,8 +5,5 @@ "rootDir": "./src" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"], - "references": [ - { "path": "../core" } - ] + "exclude": ["node_modules", "dist", "**/*.test.ts"] } \ No newline at end of file diff --git a/specs/phase-0-foundation/spec.md b/specs/phase-0-foundation/spec.md index 585d8f3..bf87331 100644 --- a/specs/phase-0-foundation/spec.md +++ b/specs/phase-0-foundation/spec.md @@ -21,11 +21,11 @@ Build the foundational Experience SDK with explainability-first architecture. Th 5. **Developer-Focused** - Built for debugging and inspection ### Success Criteria -- [ ] Runtime can register and evaluate experiences +- [x] Runtime can register and evaluate experiences +- [x] Unit test coverage > 80% (achieved 100%) - [ ] Every decision includes human-readable reasons - [ ] Works via script tag with global `experiences` object - [ ] 3 working plugins (storage, debug, banner) -- [ ] Unit test coverage > 80% - [ ] Bundle size < 15KB gzipped (with sdk-kit) - [ ] Demo site showing explainability diff --git a/tsconfig.json b/tsconfig.json index bbaf6d4..3d26e09 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,10 +4,9 @@ "strict": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "moduleResolution": "Node16", - "module": "NodeNext", + "moduleResolution": "Bundler", + "module": "ESNext", "declaration": true, - "composite": true, "declarationMap": true, "sourceMap": true, "lib": ["ES2022", "DOM"], diff --git a/vitest.config.ts b/vitest.config.ts index 5feb196..dc499c1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,9 +14,8 @@ export default defineConfig({ }, resolve: { alias: { - '@monorepo/core': resolve(__dirname, 'packages/core/src'), - '@monorepo/utils': resolve(__dirname, 'packages/utils/src'), - '@monorepo/feature-a': resolve(__dirname, 'packages/feature-a/src'), + '@prosdevlab/experience-sdk': resolve(__dirname, 'packages/core/src'), + '@prosdevlab/experience-sdk-plugins': resolve(__dirname, 'packages/plugins/src'), }, }, }); \ No newline at end of file