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:
+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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user