Server-side scenario execution: thin effects client replaces local engine
- engine/ (DagWalker, sessions, IndexedDbPlanStore) deleted; server owns the graph - trigger/callback/counter carry PendingEffect; GET /sdk/v1/scenarios/pending polled (30s jittered heartbeat paused on hidden tab + wait-deadline timers) - client.effects public API unchanged (done/buy/dismiss/end/claim/battlepass/quest) - planStateStore removed; loginEvent kept; reconcile() makes server the source of truth - generated models regenerated; skills/README/CHANGELOG updated
This commit is contained in:
@@ -30,7 +30,7 @@ describe('AuthService', () => {
|
||||
|
||||
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(8);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(9);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toContain('/sdk/v1/authorization/device');
|
||||
const body = JSON.parse(init.body);
|
||||
@@ -46,10 +46,9 @@ describe('AuthService', () => {
|
||||
'/sdk/v1/player/information',
|
||||
'/sdk/v1/stores',
|
||||
'/sdk/v1/catalog',
|
||||
'/sdk/v1/scenarios/pending',
|
||||
'/sdk/v1/scenarios/trigger',
|
||||
// The login event invalidates the warmed profile → refetch.
|
||||
'/sdk/v1/player/information',
|
||||
// Sync engine baseline poll, fired right after login.
|
||||
'/sdk/v1/sync',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('public API surface', () => {
|
||||
const client = new RudderClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
runtime: { planStateStore: null, loginEvent: null },
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
|
||||
expect(client.effects).toBeDefined();
|
||||
@@ -75,7 +75,7 @@ describe('public API surface', () => {
|
||||
const client = new RudderClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
runtime: { planStateStore: null, loginEvent: null },
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
|
||||
expect(client.remoteConfig).toBeDefined();
|
||||
|
||||
+10
-10
@@ -148,7 +148,7 @@ describe('lazy scenario runtime', () => {
|
||||
const client = new RudderClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
projectKey: 'proj_123',
|
||||
runtime: { planStateStore: null, loginEvent: null },
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
const internals = client as unknown as {
|
||||
scenarioRuntime: unknown;
|
||||
@@ -163,7 +163,7 @@ describe('lazy scenario runtime', () => {
|
||||
const client = new RudderClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
projectKey: 'proj_123',
|
||||
runtime: { planStateStore: null }, // default loginEvent: player_login
|
||||
runtime: {},
|
||||
});
|
||||
|
||||
const notifications: string[] = [];
|
||||
@@ -182,18 +182,18 @@ describe('lazy scenario runtime', () => {
|
||||
}
|
||||
if (path === '/sdk/v1/scenarios/trigger') {
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
plans: [{
|
||||
planId: 'plan-1',
|
||||
scenarioId: 'scenario-1',
|
||||
userId: 'user-1',
|
||||
startNodeId: 'start',
|
||||
effects: [{
|
||||
runId: 'run-1',
|
||||
nodes: [{ id: 'start', type: 'notification', data: { message: 'Welcome!' } }],
|
||||
edges: [],
|
||||
boundaryNodes: [],
|
||||
scenarioId: 'scenario-1',
|
||||
nodeId: 'start',
|
||||
type: 'notification',
|
||||
data: { message: 'Welcome!' },
|
||||
}],
|
||||
}), { status: 200 }));
|
||||
}
|
||||
if (path === '/sdk/v1/scenarios/pending') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ effects: [] }), { status: 200 }));
|
||||
}
|
||||
if (path === '/sdk/v1/remote-configs') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
|
||||
}
|
||||
|
||||
+258
-370
@@ -2,43 +2,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ScenarioService } from '../src/scenario/ScenarioService.js';
|
||||
import { getScenarioRuntime, RudderClient } from '../src/client/RudderClient.js';
|
||||
import { createFakeTokenStore } from './helpers/FakeTokenStore.js';
|
||||
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge } from '../src/generated/common.js';
|
||||
import { NotificationSession, StoreSession, WaitSession, LeaderboardSession } from '../src/scenario/engine/sessions.js';
|
||||
|
||||
/** Builds a simple execution plan with the given nodes and edges. */
|
||||
function makePlan(overrides?: Partial<ExecutionPlan>): ExecutionPlan {
|
||||
return {
|
||||
planId: 'plan-1',
|
||||
scenarioId: 'scenario-1',
|
||||
userId: 'user-1',
|
||||
startNodeId: 'start',
|
||||
runId: 'run-1',
|
||||
nodes: [],
|
||||
edges: [],
|
||||
boundaryNodes: [],
|
||||
context: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeNode(id: string, type: string, data?: Record<string, unknown>): ExecutionPlanNode {
|
||||
return { id, type, data: data as ExecutionPlanNode['data'] };
|
||||
}
|
||||
|
||||
function makeEdge(source: string, sourceHandle: string, target: string): PlanEdge {
|
||||
return { id: `edge-${source}-${target}`, source, sourceHandle, target };
|
||||
}
|
||||
import { effect } from './helpers/plan.js';
|
||||
import { RudderHttpError } from '../src/client/RudderError.js';
|
||||
import type { NotificationEffect, StoreOfferEffect, WaitEffect } from '../src/index.js';
|
||||
import type { PendingEffect } from '../src/generated/scenarios.js';
|
||||
|
||||
async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> {
|
||||
const client = new RudderClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-key',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null, loginEvent: null }, // Disable IndexedDB for tests
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
return { client, scenarios: await getScenarioRuntime(client) };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}
|
||||
|
||||
function stubFetch(handler: (path: string, method: string, body: unknown) => Response | Promise<Response>): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
const parsed = new URL(url);
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const body = init?.body ? JSON.parse(init.body as string) : undefined;
|
||||
return Promise.resolve(handler(parsed.pathname, method, body));
|
||||
}));
|
||||
}
|
||||
|
||||
describe('ScenarioService', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('crypto', {
|
||||
@@ -46,119 +37,100 @@ describe('ScenarioService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('send (trigger)', () => {
|
||||
it('calls POST /sdk/v1/scenarios/trigger and starts plans', async () => {
|
||||
it('calls POST /sdk/v1/scenarios/trigger and emits effects', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'notification')],
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
const onNotification = vi.fn();
|
||||
scenarios.onNotification = onNotification;
|
||||
client.effects.onNotification(onNotification);
|
||||
|
||||
stubFetch((path, method) => {
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
||||
return jsonResponse({
|
||||
effects: [effect('notification', { title: 'Hi', message: 'Welcome' })],
|
||||
});
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
const response = await scenarios.send('level_complete');
|
||||
|
||||
expect(response.plans).toHaveLength(1);
|
||||
expect(response.effects).toHaveLength(1);
|
||||
expect(scenarios.isRunning).toBe(true);
|
||||
expect(onNotification).toHaveBeenCalledOnce();
|
||||
expect(onNotification.mock.calls[0][0]).toBeInstanceOf(NotificationSession);
|
||||
expect(onNotification.mock.calls[0][0].title).toBe('Hi');
|
||||
expect(onNotification.mock.calls[0][0].message).toBe('Welcome');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node dispatch', () => {
|
||||
describe('effect emission', () => {
|
||||
it('notification node fires onNotification', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onNotif = vi.fn();
|
||||
scenarios.onNotification = onNotif;
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'notification')],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
client.effects.onNotification(onNotif);
|
||||
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
|
||||
await scenarios.send('test');
|
||||
expect(onNotif).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('store node fires onStore', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
it('store node fires onStoreOffer', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onStore = vi.fn();
|
||||
scenarios.onStore = onStore;
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'store')],
|
||||
client.effects.onStoreOffer(onStore);
|
||||
stubFetch((path) => {
|
||||
if (path === '/sdk/v1/scenarios/trigger') {
|
||||
return jsonResponse({
|
||||
effects: [effect('store', { storeSlug: 'starter', message: 'Buy!' })],
|
||||
});
|
||||
}
|
||||
if (path === '/sdk/v1/stores/starter') {
|
||||
return jsonResponse({ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', name: 'Pack' }] });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
await scenarios.send('test');
|
||||
expect(onStore).toHaveBeenCalledOnce();
|
||||
expect(onStore.mock.calls[0][0]).toBeInstanceOf(StoreSession);
|
||||
await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
|
||||
const offer = onStore.mock.calls[0][0] as StoreOfferEffect;
|
||||
expect(offer.message).toBe('Buy!');
|
||||
expect(offer.store.slug).toBe('starter');
|
||||
});
|
||||
|
||||
it('wait node fires onWait', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onWait = vi.fn();
|
||||
scenarios.onWait = onWait;
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'wait', { duration: 5, unit: 'minutes' })],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
client.effects.onWait(onWait);
|
||||
const deadline = new Date(Date.now() + 60_000).toISOString();
|
||||
stubFetch(() => jsonResponse({
|
||||
effects: [effect('wait', {}, { waitDeadline: deadline })],
|
||||
}));
|
||||
await scenarios.send('test');
|
||||
expect(onWait).toHaveBeenCalledOnce();
|
||||
expect(onWait.mock.calls[0][0]).toBeInstanceOf(WaitSession);
|
||||
expect(onWait.mock.calls[0][0].deadlineUtc).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('leaderboard node fires onLeaderboard', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onLb = vi.fn();
|
||||
scenarios.onLeaderboard = onLb;
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'leaderboard')],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
client.effects.onLeaderboard(onLb);
|
||||
stubFetch(() => jsonResponse({ effects: [effect('leaderboard')] }));
|
||||
await scenarios.send('test');
|
||||
expect(onLb).toHaveBeenCalledOnce();
|
||||
expect(onLb.mock.calls[0][0]).toBeInstanceOf(LeaderboardSession);
|
||||
});
|
||||
|
||||
it('quest node dispatches onQuest instead of stalling', async () => {
|
||||
it('quest node dispatches onQuest', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onQuest = vi.fn();
|
||||
client.effects.onQuest(onQuest);
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [
|
||||
makeNode('start', 'quest', {
|
||||
name: 'Daily',
|
||||
objectives: [{ objectiveId: 'kills', target: 10 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
stubFetch(() => jsonResponse({
|
||||
effects: [effect('quest', { name: 'Daily', objectives: [{ objectiveId: 'kills', target: 10 }] })],
|
||||
}));
|
||||
await scenarios.send('test');
|
||||
expect(onQuest).toHaveBeenCalledOnce();
|
||||
expect(onQuest.mock.calls[0][0].name).toBe('Daily');
|
||||
// Active and waiting for progress — not stalled, not failed.
|
||||
expect(scenarios.isRunning).toBe(true);
|
||||
});
|
||||
|
||||
@@ -166,14 +138,7 @@ describe('ScenarioService', () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onBp = vi.fn();
|
||||
client.effects.onBattlePass(onBp);
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'battlepass', { premiumPrice: 100 })],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
stubFetch(() => jsonResponse({ effects: [effect('battlepass', { premiumPrice: 100 })] }));
|
||||
await scenarios.send('test');
|
||||
expect(onBp).toHaveBeenCalledOnce();
|
||||
expect(scenarios.isRunning).toBe(true);
|
||||
@@ -183,346 +148,269 @@ describe('ScenarioService', () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onLevel = vi.fn();
|
||||
client.effects.onBattlePassLevel(onLevel);
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'battlepass_level', { levelNumber: 3 })],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
stubFetch(() => jsonResponse({
|
||||
effects: [effect('battlepass_level', { levelNumber: 3 })],
|
||||
}));
|
||||
await scenarios.send('test');
|
||||
expect(onLevel).toHaveBeenCalledOnce();
|
||||
expect(onLevel.mock.calls[0][0].level).toBe(3);
|
||||
});
|
||||
|
||||
it('remote_config_override node applies patches internally and auto-completes', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [
|
||||
makeNode('start', 'remote_config_override', {
|
||||
patches: [
|
||||
{ path: 'difficulty', valueType: 'string', value: 'hard' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
await scenarios.send('test');
|
||||
expect(client.remoteConfig.get('difficulty', '')).toBe('hard');
|
||||
// RemoteConfigOverride auto-completes asynchronously — wait for the promise.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(scenarios.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('unsupported node type fails the run and surfaces onScenarioFailed', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const onFailed = vi.fn();
|
||||
client.effects.onScenarioFailed(onFailed);
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'unknown_type')],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
stubFetch(() => jsonResponse({ effects: [effect('unknown_type')] }));
|
||||
await scenarios.send('test');
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unsupported scenario node type'),
|
||||
);
|
||||
// The run must NOT stall: it fails and the game is notified.
|
||||
expect(onFailed).toHaveBeenCalledOnce();
|
||||
expect(scenarios.isRunning).toBe(false);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DAG traversal', () => {
|
||||
it('completing a node follows matching edges', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
|
||||
describe('callback continuation', () => {
|
||||
it('completing an effect posts callback and emits the next effect', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onNotif = vi.fn();
|
||||
scenarios.onNotification = onNotif;
|
||||
client.effects.onNotification(onNotif);
|
||||
const first = effect('notification', { message: 'one' }, { nodeId: 'n1' });
|
||||
const second = effect('notification', { message: 'two' }, { nodeId: 'n2' });
|
||||
|
||||
// start(notif) → complete("output") → node2(notif)
|
||||
const plan = makePlan({
|
||||
nodes: [
|
||||
makeNode('start', 'notification'),
|
||||
makeNode('node2', 'notification'),
|
||||
],
|
||||
edges: [makeEdge('start', 'output', 'node2')],
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 }));
|
||||
stubFetch((path, method) => {
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
||||
return jsonResponse({ effects: [first] });
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
|
||||
}));
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
|
||||
return jsonResponse({ effect: second });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
await scenarios.send('test');
|
||||
expect(onNotif).toHaveBeenCalledOnce(); // start node dispatched
|
||||
|
||||
// Complete the notification node with handle "output"
|
||||
const session = onNotif.mock.calls[0][0] as NotificationSession;
|
||||
await session.complete();
|
||||
expect(onNotif).toHaveBeenCalledOnce();
|
||||
const session = onNotif.mock.calls[0][0] as NotificationEffect;
|
||||
await session.done();
|
||||
expect(onNotif).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(onNotif.mock.calls[1][0].message).toBe('two');
|
||||
|
||||
it('completing a node with already-completed handle is idempotent', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
|
||||
const onNotif = vi.fn();
|
||||
scenarios.onNotification = onNotif;
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [
|
||||
makeNode('start', 'notification'),
|
||||
makeNode('node2', 'notification'),
|
||||
],
|
||||
edges: [makeEdge('start', 'output', 'node2')],
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
const callbackCall = fetchMock.mock.calls.find((call) => {
|
||||
const url = String(call[0]);
|
||||
return url.includes('/sdk/v1/scenarios/callback');
|
||||
});
|
||||
expect(JSON.parse(callbackCall![1]!.body as string)).toEqual({
|
||||
scenarioId: 'scenario-1',
|
||||
nodeId: 'n1',
|
||||
handle: 'output',
|
||||
runId: 'run-1',
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
await scenarios.send('test');
|
||||
expect(scenarios.activeRuns).toHaveLength(1);
|
||||
|
||||
const session = onNotif.mock.calls[0][0] as NotificationSession;
|
||||
await session.complete();
|
||||
|
||||
// node2 should now be active
|
||||
expect(scenarios.activeRuns).toHaveLength(1);
|
||||
|
||||
// Complete node2
|
||||
const session2 = onNotif.mock.calls[1][0] as NotificationSession;
|
||||
await session2.complete();
|
||||
|
||||
// Run should be completed
|
||||
expect(scenarios.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('run completion fires onRunCompleted and onCompleted', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
|
||||
it('completing the last effect emits onScenarioCompleted', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onCompleted = vi.fn();
|
||||
const onRunCompleted = vi.fn();
|
||||
scenarios.onCompleted = onCompleted;
|
||||
scenarios.onRunCompleted = onRunCompleted;
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'notification')],
|
||||
const onNotif = vi.fn();
|
||||
client.effects.onScenarioCompleted(onCompleted);
|
||||
client.effects.onNotification(onNotif);
|
||||
stubFetch((path) => {
|
||||
if (path === '/sdk/v1/scenarios/trigger') {
|
||||
return jsonResponse({ effects: [effect('notification')] });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
await scenarios.send('test');
|
||||
const session = (scenarios as unknown as { onNotification?: (s: NotificationSession) => void }).onNotification?.(
|
||||
// Use the mock calls to get the session
|
||||
vi.mocked(vi.fn()).mock.calls[0]?.[0] as NotificationSession,
|
||||
);
|
||||
// We need to get the session from the mock spy
|
||||
// Actually let's trigger via respond()
|
||||
scenarios.respond('output');
|
||||
// Fire-and-forget — wait a tick
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(onRunCompleted).toHaveBeenCalled();
|
||||
expect(onCompleted).toHaveBeenCalled();
|
||||
await (onNotif.mock.calls[0][0] as NotificationEffect).done();
|
||||
expect(onCompleted).toHaveBeenCalledOnce();
|
||||
expect(onCompleted.mock.calls[0][0]).toEqual({
|
||||
runId: 'run-1',
|
||||
scenarioId: 'scenario-1',
|
||||
});
|
||||
expect(scenarios.isRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wait nodes', () => {
|
||||
it('sets a deadline and fires onWait', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
const onWait = vi.fn();
|
||||
scenarios.onWait = onWait;
|
||||
describe('pending dedup', () => {
|
||||
it('does not re-emit an effect with the same runId and nodeId', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onNotification = vi.fn();
|
||||
client.effects.onNotification(onNotification);
|
||||
const pending = effect('notification', { message: 'once' });
|
||||
stubFetch(() => jsonResponse({ effects: [pending] }));
|
||||
await scenarios.send('first-event');
|
||||
await scenarios.send('second-event');
|
||||
expect(onNotification).toHaveBeenCalledTimes(1);
|
||||
expect(scenarios.isRunning).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'wait', { duration: 10, unit: 'minutes' })],
|
||||
describe('waitDeadline timer', () => {
|
||||
it('polls pending at waitDeadline and emits the next effect', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onWait = vi.fn();
|
||||
const onNotif = vi.fn();
|
||||
client.effects.onWait(onWait);
|
||||
client.effects.onNotification(onNotif);
|
||||
|
||||
const deadline = new Date(Date.now() + 5_000).toISOString();
|
||||
const wait = effect('wait', {}, { waitDeadline: deadline, nodeId: 'wait-1' });
|
||||
const next = effect('notification', { message: 'after wait' }, { nodeId: 'n2' });
|
||||
let pending: PendingEffect[] = [wait];
|
||||
|
||||
stubFetch((path, method) => {
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
||||
return jsonResponse({ effects: [wait] });
|
||||
}
|
||||
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
|
||||
return jsonResponse({ effects: pending });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
await scenarios.send('test');
|
||||
expect(onWait).toHaveBeenCalledOnce();
|
||||
const session = onWait.mock.calls[0][0] as WaitSession;
|
||||
expect(session.deadlineUtc).toBeInstanceOf(Date);
|
||||
// Deadline should be ~10 minutes from now.
|
||||
const diff = session.deadlineUtc.getTime() - Date.now();
|
||||
expect(diff).toBeGreaterThan(9 * 60 * 1000);
|
||||
expect(diff).toBeLessThan(11 * 60 * 1000);
|
||||
expect(onNotif).not.toHaveBeenCalled();
|
||||
|
||||
pending = [next];
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(onNotif).toHaveBeenCalledOnce();
|
||||
expect(onNotif.mock.calls[0][0].message).toBe('after wait');
|
||||
const session = onWait.mock.calls[0][0] as WaitEffect;
|
||||
expect(session.deadlineUtc.toISOString()).toBe(deadline);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expired-run drop', () => {
|
||||
it('drops the run on unknown_run and does not retry it', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const onFailed = vi.fn();
|
||||
const onNotif = vi.fn();
|
||||
client.effects.onScenarioFailed(onFailed);
|
||||
client.effects.onNotification(onNotif);
|
||||
const pending = effect('notification', { message: 'hello' });
|
||||
|
||||
stubFetch((path) => {
|
||||
if (path === '/sdk/v1/scenarios/trigger') {
|
||||
return jsonResponse({ effects: [pending] });
|
||||
}
|
||||
if (path === '/sdk/v1/scenarios/callback') {
|
||||
return jsonResponse({ code: 'unknown_run', error: 'gone' }, 410);
|
||||
}
|
||||
if (path === '/sdk/v1/scenarios/pending') {
|
||||
return jsonResponse({ effects: [pending] });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
await scenarios.send('test');
|
||||
expect(onNotif).toHaveBeenCalledOnce();
|
||||
await expect((onNotif.mock.calls[0][0] as NotificationEffect).done())
|
||||
.rejects.toBeInstanceOf(RudderHttpError);
|
||||
expect(onFailed).toHaveBeenCalledOnce();
|
||||
expect(onFailed.mock.calls[0][0].runId).toBe('run-1');
|
||||
expect(scenarios.isRunning).toBe(false);
|
||||
|
||||
await scenarios.start();
|
||||
expect(onNotif).toHaveBeenCalledTimes(1);
|
||||
warn.mockRestore();
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('completes immediately if deadline has already passed', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
const onWait = vi.fn();
|
||||
const onCompleted = vi.fn();
|
||||
scenarios.onWait = onWait;
|
||||
scenarios.onCompleted = onCompleted;
|
||||
it('leaves the effect active on a transient callback error', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onFailed = vi.fn();
|
||||
const onNotif = vi.fn();
|
||||
client.effects.onScenarioFailed(onFailed);
|
||||
client.effects.onNotification(onNotif);
|
||||
|
||||
// Duration of 0 should result in an immediate completion.
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'wait', { duration: 0, unit: 'seconds' })],
|
||||
stubFetch((path) => {
|
||||
if (path === '/sdk/v1/scenarios/trigger') {
|
||||
return jsonResponse({ effects: [effect('notification')] });
|
||||
}
|
||||
if (path === '/sdk/v1/scenarios/callback') {
|
||||
return jsonResponse({ error: 'boom' }, 500);
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
await scenarios.send('test');
|
||||
expect(onWait).toHaveBeenCalledOnce();
|
||||
// Wait for the setTimeout(0) to fire.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(onCompleted).toHaveBeenCalled();
|
||||
await (onNotif.mock.calls[0][0] as NotificationEffect).done();
|
||||
expect(onFailed).not.toHaveBeenCalled();
|
||||
expect(scenarios.isRunning).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store session', () => {
|
||||
it('buy() purchases the selected offer and completes with onPurchase', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onStore = vi.fn();
|
||||
scenarios.onStore = onStore;
|
||||
client.effects.onStoreOffer(onStore);
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'store', { storeSlug: 'starter' })],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
const parsed = new URL(url);
|
||||
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/scenarios/trigger') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 }));
|
||||
stubFetch((path, method) => {
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
||||
return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
|
||||
}
|
||||
if (init?.method === 'GET' && parsed.pathname === '/sdk/v1/stores/starter') {
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
if (method === 'GET' && path === '/sdk/v1/stores/starter') {
|
||||
return jsonResponse({
|
||||
name: 'Starter',
|
||||
slug: 'starter',
|
||||
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
|
||||
}), { status: 200 }));
|
||||
});
|
||||
}
|
||||
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/stores/starter/offers/pack_1/purchase') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ success: true, purchaseId: 'purchase-1' }), { status: 200 }));
|
||||
if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack_1/purchase') {
|
||||
return jsonResponse({ success: true, purchaseId: 'purchase-1' });
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
|
||||
}));
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
await scenarios.send('test');
|
||||
const session = onStore.mock.calls[0][0] as StoreSession;
|
||||
|
||||
const onCompleted = vi.fn();
|
||||
scenarios.onCompleted = onCompleted;
|
||||
|
||||
const store = await session.getStore();
|
||||
const purchase = await session.buy(store.offers[0]);
|
||||
await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
|
||||
const session = onStore.mock.calls[0][0] as StoreOfferEffect;
|
||||
const purchase = await session.buy(session.offers[0]);
|
||||
expect(purchase.success).toBe(true);
|
||||
expect(session.isResolved).toBe(true);
|
||||
|
||||
// Second call should be a no-op.
|
||||
const duplicate = await session.buy(store.offers[0]);
|
||||
const duplicate = await session.buy(session.offers[0]);
|
||||
expect(duplicate.success).toBe(false);
|
||||
});
|
||||
|
||||
it('decline() completes with onDecline', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
it('dismiss() completes with onDecline', async () => {
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
const onStore = vi.fn();
|
||||
scenarios.onStore = onStore;
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'store')],
|
||||
client.effects.onStoreOffer(onStore);
|
||||
stubFetch((path) => {
|
||||
if (path === '/sdk/v1/scenarios/trigger') {
|
||||
return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
|
||||
}
|
||||
if (path === '/sdk/v1/stores/starter') {
|
||||
return jsonResponse({ slug: 'starter', offers: [{ id: 'pack_1' }] });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
await scenarios.send('test');
|
||||
const session = onStore.mock.calls[0][0] as StoreSession;
|
||||
await session.decline();
|
||||
expect(session.isResolved).toBe(true);
|
||||
await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
|
||||
await (onStore.mock.calls[0][0] as StoreOfferEffect).dismiss();
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
const callbackCall = fetchMock.mock.calls.find((call) =>
|
||||
String(call[0]).includes('/sdk/v1/scenarios/callback'),
|
||||
);
|
||||
expect(JSON.parse(callbackCall![1]!.body as string).handle).toBe('onDecline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('respond / clear', () => {
|
||||
it('respond completes the first active node', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
const onNotif = vi.fn();
|
||||
scenarios.onNotification = onNotif;
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [
|
||||
makeNode('start', 'notification'),
|
||||
makeNode('node2', 'notification'),
|
||||
],
|
||||
edges: [makeEdge('start', 'output', 'node2')],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
await scenarios.send('test');
|
||||
|
||||
// respond() completes the first active node with the given handle.
|
||||
scenarios.respond('output');
|
||||
|
||||
// Wait for async completion.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// node2 should now be active (second notification dispatched).
|
||||
expect(scenarios.isRunning).toBe(true);
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('clear removes all runs', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
|
||||
const plan = makePlan({
|
||||
nodes: [makeNode('start', 'notification')],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||
));
|
||||
|
||||
const { client, scenarios } = await createClientWithScenario();
|
||||
client.effects.onNotification(vi.fn());
|
||||
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
|
||||
await scenarios.send('test');
|
||||
expect(scenarios.isRunning).toBe(true);
|
||||
|
||||
scenarios.clear();
|
||||
expect(scenarios.isRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runId filtering', () => {
|
||||
it('rejects plans with duplicate runId', async () => {
|
||||
const { scenarios } = await createClientWithScenario();
|
||||
|
||||
const onNotification = vi.fn();
|
||||
scenarios.onNotification = onNotification;
|
||||
|
||||
const plan = makePlan({
|
||||
runId: 'run-1',
|
||||
nodes: [makeNode('start', 'notification')],
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })),
|
||||
));
|
||||
|
||||
// First send creates a run
|
||||
await scenarios.send('first-event');
|
||||
expect(scenarios.activeRuns).toHaveLength(1);
|
||||
expect(onNotification).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second send with same runId is rejected by startPlan dedup
|
||||
await scenarios.send('second-event');
|
||||
expect(scenarios.activeRuns).toHaveLength(1);
|
||||
expect(onNotification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,11 +9,6 @@ import { readArtifact } from './artifact.js';
|
||||
import type { SeedArtifact } from './types.js';
|
||||
|
||||
type RuntimeOptions = {
|
||||
planStateStore?: {
|
||||
state: string | null;
|
||||
load(): Promise<void>;
|
||||
save(state: string | null): Promise<void>;
|
||||
} | null;
|
||||
loginEvent?: string | null;
|
||||
};
|
||||
|
||||
@@ -52,7 +47,7 @@ export function createInMemoryTokenStore(): TokenStore {
|
||||
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
|
||||
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
||||
artifact: SeedArtifact = loadArtifact(),
|
||||
runtime: RuntimeOptions = { planStateStore: null, loginEvent: null },
|
||||
runtime: RuntimeOptions = { loginEvent: null },
|
||||
): RudderClient<TConfig> {
|
||||
return new RudderClient<TConfig>({
|
||||
baseUrl: artifact.baseUrl,
|
||||
|
||||
@@ -16,7 +16,6 @@ describe('e2e-prod: scenarios', () => {
|
||||
let runCompleted = false;
|
||||
|
||||
const client = makeProdClient(artifact, {
|
||||
planStateStore: null,
|
||||
loginEvent: artifact.scenario.event,
|
||||
});
|
||||
|
||||
@@ -25,9 +24,9 @@ describe('e2e-prod: scenarios', () => {
|
||||
storeOffer = effect;
|
||||
});
|
||||
const runtime = await getScenarioRuntime(client);
|
||||
runtime.onRunCompleted = () => {
|
||||
client.effects.onScenarioCompleted(() => {
|
||||
runCompleted = true;
|
||||
};
|
||||
});
|
||||
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
|
||||
|
||||
// The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
|
||||
@@ -45,8 +44,8 @@ describe('e2e-prod: scenarios', () => {
|
||||
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
|
||||
await notifications[0].done();
|
||||
|
||||
// remote_config_override applies, then the store offer surfaces.
|
||||
await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 });
|
||||
await client.remoteConfig.reload();
|
||||
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(
|
||||
artifact.remoteConfigs.spawnRateOverride,
|
||||
);
|
||||
|
||||
@@ -3,44 +3,17 @@ import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.
|
||||
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||
import { plan } from '../helpers/plan.js';
|
||||
import { effect } from '../helpers/plan.js';
|
||||
import type { StoreOfferEffect } from '../../src/index.js';
|
||||
import type { PlanRun, ScenarioRunFailedEvent } from '../../src/scenario/engine/types.js';
|
||||
|
||||
function makeClient(): RudderClient {
|
||||
return new RudderClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An offer whose `onPurchase` continuation lives server-side (boundary node):
|
||||
* the client doesn't hold the post-purchase DAG — it must call back so the
|
||||
* server can validate the transaction and decide what happens next.
|
||||
*
|
||||
* `onDecline`, by contrast, is a plain local edge handled entirely on-device.
|
||||
*/
|
||||
function offerWithBoundary() {
|
||||
return plan('offer_flow')
|
||||
.runId('offer_flow-run')
|
||||
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy the starter pack!' })
|
||||
.node('consolation', 'notification', { message: 'Maybe next time!' })
|
||||
.edge('offer', 'onDecline', 'consolation')
|
||||
.boundary('offer', 'onPurchase') // server owns what comes after a purchase
|
||||
.build();
|
||||
}
|
||||
|
||||
/** What the server returns from the callback — a fresh plan = a new run. */
|
||||
function rewardContinuation() {
|
||||
return plan('reward_flow')
|
||||
.runId('reward_flow-run')
|
||||
.node('reward', 'notification', { message: 'Reward granted: 500 gems!' })
|
||||
.build();
|
||||
}
|
||||
|
||||
describe('E2E: boundary nodes (server-side continuation)', () => {
|
||||
let gateway: FakeGateway;
|
||||
let client: RudderClient;
|
||||
@@ -61,99 +34,93 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
|
||||
gateway.onEvent('player_login', offerWithBoundary());
|
||||
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
|
||||
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'offer',
|
||||
});
|
||||
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'reward',
|
||||
});
|
||||
gateway.onEvent('player_login', offerEffect);
|
||||
gateway.onCallback('offer', 'onPurchase', reward);
|
||||
|
||||
const messages: string[] = [];
|
||||
const completed: PlanRun[] = [];
|
||||
const completed: string[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
const runtime = await getScenarioRuntime(client);
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
||||
runtime.onRunCompleted = (r) => completed.push(r);
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onNotification((e) => { messages.push(e.message); });
|
||||
client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioId); });
|
||||
|
||||
const token = (await client.auth.loginWithDevice()).accessToken;
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
|
||||
await offer!.buy(offer!.offers[0]);
|
||||
|
||||
// The callback hit the gateway with the boundary's source node/handle + token.
|
||||
const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
|
||||
expect(callback?.body).toEqual({ scenarioId: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run' });
|
||||
expect(callback?.body).toEqual({
|
||||
scenarioId: 'offer_flow',
|
||||
nodeId: 'offer',
|
||||
handle: 'onPurchase',
|
||||
runId: 'offer_flow-run',
|
||||
});
|
||||
expect(callback?.authToken).toBe(token);
|
||||
|
||||
// The server-supplied continuation ran (as a new run), not the local edge.
|
||||
expect(messages).toEqual(['Reward granted: 500 gems!']);
|
||||
expect(messages).not.toContain('Maybe next time!');
|
||||
|
||||
// The original run finished; the continuation ('reward_flow') is now active.
|
||||
expect(completed.map((r) => r.scenarioId)).toContain('offer_flow');
|
||||
expect(runtime.activeRuns.map((r) => r.scenarioId)).toEqual(['reward_flow']);
|
||||
expect(completed).toEqual([]);
|
||||
});
|
||||
|
||||
it('decline stays local → no callback, local edge is followed', async () => {
|
||||
gateway.onEvent('player_login', offerWithBoundary());
|
||||
it('decline posts callback and continues with the server-supplied next effect', async () => {
|
||||
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'offer',
|
||||
});
|
||||
const consolation = effect('notification', { message: 'Maybe next time!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'consolation',
|
||||
});
|
||||
gateway.onEvent('player_login', offerEffect);
|
||||
gateway.onCallback('offer', 'onDecline', consolation);
|
||||
|
||||
const messages: string[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onNotification((e) => { messages.push(e.message); });
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
await offer!.dismiss();
|
||||
|
||||
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(false);
|
||||
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(true);
|
||||
expect(messages).toEqual(['Maybe next time!']);
|
||||
});
|
||||
|
||||
it('a boundary takes precedence over a local edge on the SAME handle', async () => {
|
||||
// `offer` has BOTH a boundary AND a local edge on onPurchase.
|
||||
const conflicting = plan('offer_flow')
|
||||
.runId('offer_flow-conflict')
|
||||
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy!' })
|
||||
.node('local_next', 'notification', { message: 'LOCAL branch' })
|
||||
.edge('offer', 'onPurchase', 'local_next')
|
||||
.boundary('offer', 'onPurchase')
|
||||
.build();
|
||||
gateway.onEvent('player_login', conflicting);
|
||||
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
|
||||
|
||||
const messages: string[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
await offer!.buy(offer!.offers[0]);
|
||||
|
||||
// Only the server continuation ran; the local edge was suppressed.
|
||||
expect(messages).toEqual(['Reward granted: 500 gems!']);
|
||||
expect(messages).not.toContain('LOCAL branch');
|
||||
});
|
||||
|
||||
it('a failing callback does not fail or crash the run', async () => {
|
||||
gateway.onEvent('player_login', offerWithBoundary());
|
||||
const offerEffect = effect('store', { storeSlug: 'starter' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'offer',
|
||||
});
|
||||
gateway.onEvent('player_login', offerEffect);
|
||||
gateway.onCallbackError('offer', 'onPurchase', 500);
|
||||
|
||||
const failures: ScenarioRunFailedEvent[] = [];
|
||||
const completed: PlanRun[] = [];
|
||||
const failures: unknown[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
const runtime = await getScenarioRuntime(client);
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
runtime.onRunFailed = (e) => failures.push(e);
|
||||
runtime.onRunCompleted = (r) => completed.push(r);
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onScenarioFailed((e) => { failures.push(e); });
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
|
||||
// Must not throw despite the 500 from the callback endpoint.
|
||||
await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
|
||||
|
||||
expect(failures).toHaveLength(0); // 500 is transient — no terminal failure
|
||||
// The run stays pending because the boundary call failed transiently.
|
||||
// On reconnection the consumer can retry the purchase completion.
|
||||
expect(failures).toHaveLength(0);
|
||||
expect(runtime.isRunning).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RudderClient } from '../../src/client/RudderClient.js';
|
||||
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||
import { plan } from '../helpers/plan.js';
|
||||
import { effect } from '../helpers/plan.js';
|
||||
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
@@ -13,28 +13,38 @@ function makeClient(): RudderClient {
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null }, // exercised separately in the persistence test
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The offer scenario both branches share:
|
||||
*
|
||||
* login → wait 1m → offer ──onDecline──→ wait 1m → "still available"
|
||||
* └─onPurchase─→ "thanks for your purchase"
|
||||
*/
|
||||
function offerScenario() {
|
||||
return plan('offer_flow')
|
||||
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
|
||||
.node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' })
|
||||
.node('wait_reminder', 'wait', { duration: 1, unit: 'minutes' })
|
||||
.node('reminder', 'notification', { message: 'Your offer is still available!' })
|
||||
.node('thanks', 'notification', { message: 'Thanks for your purchase!' })
|
||||
.edge('wait_intro', 'onComplete', 'offer')
|
||||
.edge('offer', 'onDecline', 'wait_reminder')
|
||||
.edge('wait_reminder', 'onComplete', 'reminder')
|
||||
.edge('offer', 'onPurchase', 'thanks')
|
||||
.build();
|
||||
function offerScenario(now: number) {
|
||||
const waitIntro = effect('wait', {}, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'wait_intro',
|
||||
waitDeadline: new Date(now + MINUTE).toISOString(),
|
||||
});
|
||||
const offer = effect('store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'offer',
|
||||
});
|
||||
const waitReminder = effect('wait', {}, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'wait_reminder',
|
||||
waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
|
||||
});
|
||||
const reminder = effect('notification', { message: 'Your offer is still available!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'reminder',
|
||||
});
|
||||
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'thanks',
|
||||
});
|
||||
return { waitIntro, offer, waitReminder, reminder, thanks };
|
||||
}
|
||||
|
||||
describe('E2E: player offer journey', () => {
|
||||
@@ -66,43 +76,41 @@ describe('E2E: player offer journey', () => {
|
||||
expect(res.accessToken).toBeTruthy();
|
||||
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken);
|
||||
|
||||
// The login request carried the project key + a device id.
|
||||
const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device');
|
||||
expect(login?.body).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' });
|
||||
expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy();
|
||||
|
||||
// The post-login runtime trigger carries the bearer token.
|
||||
const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger');
|
||||
expect(trigger?.authToken).toBe(res.accessToken);
|
||||
});
|
||||
|
||||
it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => {
|
||||
gateway.onEvent('player_login', offerScenario());
|
||||
const now = Date.now();
|
||||
const nodes = offerScenario(now);
|
||||
gateway.onEvent('player_login', nodes.waitIntro);
|
||||
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
|
||||
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
|
||||
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
|
||||
|
||||
const notifications: NotificationEffect[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { notifications.push(effect); });
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onNotification((e) => { notifications.push(e); });
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
|
||||
// Immediately after login the player is just waiting — no offer yet.
|
||||
expect(offer).toBeUndefined();
|
||||
|
||||
// The offer must NOT appear before the full minute has elapsed...
|
||||
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
|
||||
expect(offer).toBeUndefined();
|
||||
|
||||
// ...and surfaces exactly when the minute is up.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
expect(offer!.message).toBe('Limited starter pack!');
|
||||
|
||||
// Player declines → the DAG moves to the reminder wait, no notification yet.
|
||||
await offer!.dismiss();
|
||||
expect(notifications).toHaveLength(0);
|
||||
|
||||
// The reminder also honours the full minute, not a moment sooner.
|
||||
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
|
||||
expect(notifications).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
@@ -111,38 +119,46 @@ describe('E2E: player offer journey', () => {
|
||||
});
|
||||
|
||||
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
|
||||
gateway.onEvent('player_login', offerScenario());
|
||||
const now = Date.now();
|
||||
const nodes = offerScenario(now);
|
||||
gateway.onEvent('player_login', nodes.waitIntro);
|
||||
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
|
||||
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
|
||||
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
const notifications: NotificationEffect[] = [];
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { notifications.push(effect); });
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onNotification((e) => { notifications.push(e); });
|
||||
|
||||
const accessToken = (await client.auth.loginWithDevice()).accessToken;
|
||||
await vi.advanceTimersByTimeAsync(MINUTE);
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
|
||||
// The game buys a selected offer; the session advances only after success.
|
||||
const selectedOffer = offer!.offers[0];
|
||||
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
|
||||
expect(purchase.success).toBe(true);
|
||||
|
||||
// The purchase hit the gateway with idempotency key + bearer token.
|
||||
expect(gateway.purchases).toEqual([
|
||||
{ storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken },
|
||||
]);
|
||||
|
||||
// The scenario took the onPurchase branch → "thanks", and the run finished.
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].message).toBe('Thanks for your purchase!');
|
||||
});
|
||||
|
||||
it('declining does NOT take the purchase branch (handles are isolated)', async () => {
|
||||
gateway.onEvent('player_login', offerScenario());
|
||||
const now = Date.now();
|
||||
const nodes = offerScenario(now);
|
||||
gateway.onEvent('player_login', nodes.waitIntro);
|
||||
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
|
||||
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
|
||||
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
|
||||
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
|
||||
|
||||
const messages: string[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onNotification((e) => { messages.push(e.message); });
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
await vi.advanceTimersByTimeAsync(MINUTE);
|
||||
|
||||
@@ -2,39 +2,44 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
|
||||
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||
import { createMemoryPlanStore, stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||
import { plan } from '../helpers/plan.js';
|
||||
import type { StoreSession, WaitSession } from '../../src/scenario/engine/sessions.js';
|
||||
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||
import { effect } from '../helpers/plan.js';
|
||||
import type { StoreOfferEffect, WaitEffect } from '../../src/index.js';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
describe('E2E: scenario state survives a reload', () => {
|
||||
describe('E2E: pending effects resume after a new login', () => {
|
||||
let gateway: FakeGateway;
|
||||
// Shared backing store mimics IndexedDB persisting across page loads.
|
||||
const backing = { value: null as string | null };
|
||||
|
||||
function clientWithSharedStore(): RudderClient {
|
||||
function makeClient(): RudderClient {
|
||||
return new RudderClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: createMemoryPlanStore(backing), loginEvent: null },
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubDeterministicUuid();
|
||||
vi.useFakeTimers();
|
||||
backing.value = null;
|
||||
gateway = createFakeGateway();
|
||||
gateway.onEvent(
|
||||
'session_start',
|
||||
plan('offer_flow')
|
||||
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
|
||||
.node('offer', 'store', { message: 'Limited starter pack!' })
|
||||
.edge('wait_intro', 'onComplete', 'offer')
|
||||
.build(),
|
||||
);
|
||||
gateway = createFakeGateway({
|
||||
stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', name: 'Pack' }] }],
|
||||
});
|
||||
const now = Date.now();
|
||||
const wait = effect('wait', {}, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'wait_intro',
|
||||
waitDeadline: new Date(now + MINUTE).toISOString(),
|
||||
});
|
||||
const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'offer',
|
||||
});
|
||||
gateway.onEvent('session_start', wait);
|
||||
gateway.onCallback('wait_intro', 'onComplete', offer);
|
||||
gateway.install();
|
||||
});
|
||||
|
||||
@@ -43,33 +48,26 @@ describe('E2E: scenario state survives a reload', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('a wait started before reload resumes and fires the offer after reload', async () => {
|
||||
// --- Session 1: trigger the scenario, then "close the tab" mid-wait ---
|
||||
const client1 = clientWithSharedStore();
|
||||
it('a wait started before reload resumes from GET pending after login', async () => {
|
||||
const client1 = makeClient();
|
||||
await client1.auth.loginWithDevice();
|
||||
const runtime1 = await getScenarioRuntime(client1);
|
||||
await runtime1.send('session_start');
|
||||
expect(runtime1.isRunning).toBe(true);
|
||||
expect(backing.value).toBeTruthy(); // run was persisted
|
||||
|
||||
// --- Session 2: fresh client, same persisted state (page reload) ---
|
||||
const client2 = clientWithSharedStore();
|
||||
const runtime2 = await getScenarioRuntime(client2);
|
||||
const resumedWaits: WaitSession[] = [];
|
||||
let offer: StoreSession | undefined;
|
||||
runtime2.onWait = (s) => resumedWaits.push(s);
|
||||
runtime2.onStore = (s) => { offer = s; };
|
||||
const client2 = makeClient();
|
||||
const resumedWaits: WaitEffect[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
client2.effects.onWait((s) => { resumedWaits.push(s); });
|
||||
client2.effects.onStoreOffer((s) => { offer = s; });
|
||||
|
||||
await client2.auth.loginWithDevice();
|
||||
|
||||
// The wait node was rehydrated and re-dispatched.
|
||||
expect(runtime2.isRunning).toBe(true);
|
||||
expect(resumedWaits).toHaveLength(1);
|
||||
expect(offer).toBeUndefined();
|
||||
|
||||
// The remaining wait time still elapses → the offer surfaces on the new client.
|
||||
await vi.advanceTimersByTimeAsync(MINUTE);
|
||||
expect(offer).toBeDefined();
|
||||
expect(offer!.get('message', '')).toBe('Limited starter pack!');
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
expect(offer!.message).toBe('Limited starter pack!');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { RudderClient } from '../../src/client/RudderClient.js';
|
||||
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||
import { plan } from '../helpers/plan.js';
|
||||
import type { RemoteConfig } from '../../src/generated/remote-config.js';
|
||||
|
||||
interface GameRemoteConfig extends Record<string, unknown> {
|
||||
@@ -25,7 +24,7 @@ function makeClient(): RudderClient<GameRemoteConfig> {
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null, loginEvent: null },
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,26 +75,4 @@ describe('E2E: remote config', () => {
|
||||
expect(client.remoteConfig.status).toBe('idle');
|
||||
expect(client.remoteConfig.get('max_energy', 99)).toBe(99);
|
||||
});
|
||||
|
||||
it('a remote_config_override scenario node patches the live cache', async () => {
|
||||
client = new RudderClient<GameRemoteConfig>({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null },
|
||||
});
|
||||
gateway.onEvent(
|
||||
'player_login',
|
||||
plan('boost')
|
||||
.node('override', 'remote_config_override', {
|
||||
patches: [{ path: 'drop_rate', valueType: 'float', value: '0.9' }],
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
|
||||
// The override is reflected immediately, without a reload.
|
||||
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ function makeClient(): RudderClient {
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null, loginEvent: null },
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+76
-53
@@ -1,5 +1,5 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { ExecutionPlan } from '../../src/generated/common.js';
|
||||
import type { PendingEffect } from '../../src/generated/scenarios.js';
|
||||
import type { RemoteConfig } from '../../src/generated/remote-config.js';
|
||||
import type { Store } from '../../src/generated/stores.js';
|
||||
import type { StorageItem } from '../../src/generated/storage.js';
|
||||
@@ -8,18 +8,6 @@ import type {
|
||||
ListQuestsResponse,
|
||||
} from '../../src/generated/quests.js';
|
||||
|
||||
/**
|
||||
* In-process fake of the LiveOps API gateway.
|
||||
*
|
||||
* Stubs the global `fetch` and routes requests the same way the real gateway
|
||||
* does — so tests exercise the *real* SDK transport, auth-header injection,
|
||||
* scenario engine and DAG traversal, just against deterministic in-memory state
|
||||
* instead of a live backend.
|
||||
*
|
||||
* Seed state up front (scenarios per event, remote configs, stores), then drive
|
||||
* the SDK through a journey and assert on `recorded` requests / server state.
|
||||
*/
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
@@ -29,36 +17,28 @@ export interface RecordedRequest {
|
||||
}
|
||||
|
||||
export interface FakeGatewayState {
|
||||
/** Plans returned by POST /scenarios/trigger, keyed by event name. */
|
||||
scenarios: Map<string, ExecutionPlan[]>;
|
||||
/** Plans returned by POST /scenarios/callback, keyed by `nodeId:handle`. */
|
||||
callbacks: Map<string, ExecutionPlan>;
|
||||
/** HTTP status to fail a callback with, keyed by `nodeId:handle`. */
|
||||
callbackErrors: Map<string, number>;
|
||||
/** Optional response for POST /scenarios/counter. */
|
||||
counterPlan?: ExecutionPlan;
|
||||
scenarios: Map<string, PendingEffect[]>;
|
||||
callbacks: Map<string, PendingEffect | null>;
|
||||
callbackErrors: Map<string, { status: number; code?: string }>;
|
||||
pendingEffects: PendingEffect[];
|
||||
counterCompleted: boolean;
|
||||
counterEffect?: PendingEffect;
|
||||
remoteConfigs: Record<string, RemoteConfig>;
|
||||
stores: Store[];
|
||||
quests: ListQuestsResponse;
|
||||
questClaims: Map<string, ClaimQuestResponse>;
|
||||
/** Player KV storage, keyed by item id. */
|
||||
storage: Map<string, StorageItem>;
|
||||
}
|
||||
|
||||
export interface FakeGateway {
|
||||
state: FakeGatewayState;
|
||||
recorded: RecordedRequest[];
|
||||
/** Purchases received, in order. */
|
||||
purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>;
|
||||
/** Quest claim requests received, in order. */
|
||||
questClaims: Array<{ questId: string; authToken: string | null }>;
|
||||
install(): void;
|
||||
/** Convenience: register the plans returned for a trigger event. */
|
||||
onEvent(event: string, ...plans: ExecutionPlan[]): void;
|
||||
/** Convenience: register the plan returned when a boundary handle calls back. */
|
||||
onCallback(nodeId: string, handle: string, plan: ExecutionPlan): void;
|
||||
/** Convenience: make a boundary callback fail with an HTTP status (default 500). */
|
||||
onCallbackError(nodeId: string, handle: string, status?: number): void;
|
||||
onEvent(event: string, ...effects: PendingEffect[]): void;
|
||||
onCallback(nodeId: string, handle: string, next: PendingEffect | null): void;
|
||||
onCallbackError(nodeId: string, handle: string, status?: number, code?: string): void;
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
@@ -70,12 +50,19 @@ function json(body: unknown, status = 200): Response {
|
||||
|
||||
const noContent = (): Response => new Response(null, { status: 204 });
|
||||
|
||||
function replaceRun(pending: PendingEffect[], next: PendingEffect[]): PendingEffect[] {
|
||||
const runIds = new Set(next.map((effect) => effect.runId));
|
||||
return [...pending.filter((effect) => !runIds.has(effect.runId)), ...next];
|
||||
}
|
||||
|
||||
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
|
||||
const state: FakeGatewayState = {
|
||||
scenarios: seed?.scenarios ?? new Map(),
|
||||
callbacks: seed?.callbacks ?? new Map(),
|
||||
callbackErrors: seed?.callbackErrors ?? new Map(),
|
||||
counterPlan: seed?.counterPlan,
|
||||
pendingEffects: seed?.pendingEffects ?? [],
|
||||
counterCompleted: seed?.counterCompleted ?? false,
|
||||
counterEffect: seed?.counterEffect,
|
||||
remoteConfigs: seed?.remoteConfigs ?? {},
|
||||
stores: seed?.stores ?? [],
|
||||
quests: seed?.quests ?? { quests: [] },
|
||||
@@ -87,10 +74,32 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
const purchases: FakeGateway['purchases'] = [];
|
||||
const questClaims: FakeGateway['questClaims'] = [];
|
||||
|
||||
function advanceExpiredWaits(): PendingEffect[] {
|
||||
const now = Date.now();
|
||||
const next: PendingEffect[] = [];
|
||||
for (const effect of state.pendingEffects) {
|
||||
if (
|
||||
effect.type === 'wait' &&
|
||||
effect.waitDeadline &&
|
||||
Date.parse(effect.waitDeadline) <= now
|
||||
) {
|
||||
const continued = state.callbacks.get(`${effect.nodeId}:onComplete`);
|
||||
if (continued === undefined) {
|
||||
next.push(effect);
|
||||
} else if (continued !== null) {
|
||||
next.push(continued);
|
||||
}
|
||||
} else {
|
||||
next.push(effect);
|
||||
}
|
||||
}
|
||||
state.pendingEffects = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function handle(req: RecordedRequest): Promise<Response> {
|
||||
const { method, path, query, body } = req;
|
||||
|
||||
// --- Auth ---
|
||||
if (method === 'POST' && path === '/sdk/v1/authorization/device') {
|
||||
const b = body as { deviceId?: string };
|
||||
return json({
|
||||
@@ -99,39 +108,56 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
});
|
||||
}
|
||||
|
||||
// --- Player ---
|
||||
if (method === 'GET' && path === '/sdk/v1/player/information') {
|
||||
return json({ player: null, wallets: [] });
|
||||
}
|
||||
|
||||
// --- Sync (baseline revision poll after login) ---
|
||||
if (method === 'GET' && path === '/sdk/v1/sync') {
|
||||
return json({});
|
||||
}
|
||||
|
||||
// --- Catalog ---
|
||||
if (method === 'GET' && path === '/sdk/v1/catalog') {
|
||||
return json({ items: [] });
|
||||
}
|
||||
|
||||
// --- Scenarios ---
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
||||
const event = (body as { event?: string }).event ?? '';
|
||||
return json({ plans: state.scenarios.get(event) ?? [] });
|
||||
const effects = state.scenarios.get(event) ?? [];
|
||||
state.pendingEffects = replaceRun(state.pendingEffects, effects);
|
||||
return json({ effects });
|
||||
}
|
||||
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
|
||||
return json({ effects: advanceExpiredWaits() });
|
||||
}
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
|
||||
const b = body as { nodeId?: string; handle?: string };
|
||||
const b = body as { nodeId?: string; handle?: string; runId?: string };
|
||||
const key = `${b.nodeId}:${b.handle}`;
|
||||
const errStatus = state.callbackErrors.get(key);
|
||||
if (errStatus) return json({ error: 'callback failed' }, errStatus);
|
||||
const plan = state.callbacks.get(key);
|
||||
return json(plan ? { plan } : {});
|
||||
const err = state.callbackErrors.get(key);
|
||||
if (err) {
|
||||
return json({ code: err.code ?? 'callback failed', error: 'callback failed' }, err.status);
|
||||
}
|
||||
const next = state.callbacks.get(key);
|
||||
state.pendingEffects = state.pendingEffects.filter(
|
||||
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
|
||||
);
|
||||
if (next) {
|
||||
state.pendingEffects = replaceRun(state.pendingEffects, [next]);
|
||||
return json({ effect: next });
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
|
||||
return json(state.counterPlan ? { plan: state.counterPlan, completed: false } : { completed: false });
|
||||
if (state.counterCompleted && state.counterEffect) {
|
||||
state.pendingEffects = replaceRun(state.pendingEffects, [state.counterEffect]);
|
||||
} else if (state.counterCompleted) {
|
||||
const b = body as { nodeId?: string; runId?: string };
|
||||
state.pendingEffects = state.pendingEffects.filter(
|
||||
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
|
||||
);
|
||||
}
|
||||
return json({ completed: state.counterCompleted, effect: state.counterEffect });
|
||||
}
|
||||
|
||||
// --- Remote config ---
|
||||
if (method === 'GET' && path === '/sdk/v1/remote-configs') {
|
||||
return json({ configs: state.remoteConfigs });
|
||||
}
|
||||
@@ -141,7 +167,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
return cfg ? json(cfg) : json({ error: 'not found' }, 404);
|
||||
}
|
||||
|
||||
// --- Stores ---
|
||||
if (method === 'GET' && path === '/sdk/v1/stores') {
|
||||
return json({ stores: state.stores, total: state.stores.length });
|
||||
}
|
||||
@@ -163,7 +188,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
return store ? json(store) : json({ error: 'not found' }, 404);
|
||||
}
|
||||
|
||||
// --- Quests ---
|
||||
if (method === 'POST' && path === '/sdk/v1/quests/list') {
|
||||
return json(state.quests);
|
||||
}
|
||||
@@ -174,7 +198,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' });
|
||||
}
|
||||
|
||||
// --- Storage ---
|
||||
if (path === '/sdk/v1/storage') {
|
||||
if (method === 'GET') {
|
||||
const typeFilter = query.get('types');
|
||||
@@ -228,14 +251,14 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
purchases,
|
||||
questClaims,
|
||||
install,
|
||||
onEvent(event, ...plans) {
|
||||
state.scenarios.set(event, plans);
|
||||
onEvent(event, ...effects) {
|
||||
state.scenarios.set(event, effects);
|
||||
},
|
||||
onCallback(nodeId, handle, plan) {
|
||||
state.callbacks.set(`${nodeId}:${handle}`, plan);
|
||||
onCallback(nodeId, handle, next) {
|
||||
state.callbacks.set(`${nodeId}:${handle}`, next);
|
||||
},
|
||||
onCallbackError(nodeId, handle, status = 500) {
|
||||
state.callbackErrors.set(`${nodeId}:${handle}`, status);
|
||||
onCallbackError(nodeId, handle, status = 500, code) {
|
||||
state.callbackErrors.set(`${nodeId}:${handle}`, { status, code });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,29 +1,3 @@
|
||||
import type { PlanStateStore } from '../../src/scenario/engine/IndexedDbPlanStore.js';
|
||||
|
||||
/**
|
||||
* In-memory PlanStateStore that survives across RudderClient instances — lets a
|
||||
* test simulate a page reload: drive scenario A on one client, construct a fresh
|
||||
* client sharing the same `backing`, call `scenarios.restore()`, and assert the
|
||||
* run resumed mid-DAG.
|
||||
*/
|
||||
export function createMemoryPlanStore(backing: { value: string | null } = { value: null }): PlanStateStore {
|
||||
return {
|
||||
get state() {
|
||||
return backing.value;
|
||||
},
|
||||
set state(v: string | null) {
|
||||
backing.value = v;
|
||||
},
|
||||
async load() {
|
||||
/* state already in `backing` */
|
||||
},
|
||||
async save(s: string | null) {
|
||||
backing.value = s;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Deterministic, collision-free crypto.randomUUID() stub for scenario run IDs. */
|
||||
export function stubDeterministicUuid(): void {
|
||||
let n = 0;
|
||||
const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto);
|
||||
|
||||
+14
-70
@@ -1,72 +1,16 @@
|
||||
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../src/generated/common.js';
|
||||
import type { PendingEffect } from '../../src/generated/scenarios.js';
|
||||
|
||||
/**
|
||||
* Small fluent builder for ExecutionPlans, so scenario journeys read like the
|
||||
* DAGs they model rather than walls of object literals.
|
||||
*
|
||||
* plan('offer_flow')
|
||||
* .node('wait1', 'wait', { duration: 1, unit: 'minutes' })
|
||||
* .node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1' })
|
||||
* .edge('wait1', 'onComplete', 'offer')
|
||||
* .build();
|
||||
*
|
||||
* The first node added becomes the start node unless `.start(id)` is called.
|
||||
*/
|
||||
export class PlanBuilder {
|
||||
private readonly nodes: ExecutionPlanNode[] = [];
|
||||
private readonly edges: PlanEdge[] = [];
|
||||
private readonly boundaryNodes: BoundaryNode[] = [];
|
||||
private startNodeId?: string;
|
||||
private runIdValue?: string;
|
||||
|
||||
constructor(
|
||||
private readonly scenarioId: string,
|
||||
private readonly opts: { planId?: string; userId?: string } = {},
|
||||
) {}
|
||||
|
||||
/** Sets a specific run ID (default: auto-derived from scenarioId). */
|
||||
runId(id: string): this {
|
||||
this.runIdValue = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
node(id: string, type: string, data?: Record<string, unknown>): this {
|
||||
this.nodes.push({ id, type, data: data as ExecutionPlanNode['data'] });
|
||||
if (!this.startNodeId) this.startNodeId = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
edge(source: string, sourceHandle: string, target: string): this {
|
||||
this.edges.push({ id: `e-${source}-${sourceHandle}-${target}`, source, sourceHandle, target });
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Registers a server-side boundary node (handle that calls back to the gateway). */
|
||||
boundary(sourceNodeId: string, sourceHandle: string, nodeId = `b-${sourceNodeId}-${sourceHandle}`): this {
|
||||
this.boundaryNodes.push({ sourceNodeId, sourceHandle, nodeId });
|
||||
return this;
|
||||
}
|
||||
|
||||
start(id: string): this {
|
||||
this.startNodeId = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): ExecutionPlan {
|
||||
return {
|
||||
planId: this.opts.planId ?? `${this.scenarioId}-plan`,
|
||||
scenarioId: this.scenarioId,
|
||||
userId: this.opts.userId ?? 'player-1',
|
||||
startNodeId: this.startNodeId,
|
||||
runId: this.runIdValue ?? `${this.scenarioId}-run`,
|
||||
nodes: this.nodes,
|
||||
edges: this.edges,
|
||||
boundaryNodes: this.boundaryNodes,
|
||||
context: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function plan(scenarioId: string, opts?: { planId?: string; userId?: string }): PlanBuilder {
|
||||
return new PlanBuilder(scenarioId, opts);
|
||||
export function effect(
|
||||
type: string,
|
||||
data: Record<string, unknown> = {},
|
||||
overrides: Partial<PendingEffect> = {},
|
||||
): PendingEffect {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
scenarioId: 'scenario-1',
|
||||
nodeId: `${type}-1`,
|
||||
type,
|
||||
data,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user