529 lines
18 KiB
TypeScript
529 lines
18 KiB
TypeScript
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 };
|
|
}
|
|
|
|
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
|
|
});
|
|
return { client, scenarios: await getScenarioRuntime(client) };
|
|
}
|
|
|
|
describe('ScenarioService', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('crypto', {
|
|
randomUUID: () => '00000000-0000-4000-a000-000000000001',
|
|
});
|
|
});
|
|
|
|
describe('send (trigger)', () => {
|
|
it('calls POST /sdk/v1/scenarios/trigger and starts plans', 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;
|
|
|
|
const response = await scenarios.send('level_complete');
|
|
|
|
expect(response.plans).toHaveLength(1);
|
|
expect(scenarios.isRunning).toBe(true);
|
|
expect(onNotification).toHaveBeenCalledOnce();
|
|
expect(onNotification.mock.calls[0][0]).toBeInstanceOf(NotificationSession);
|
|
});
|
|
});
|
|
|
|
describe('node dispatch', () => {
|
|
it('notification node fires onNotification', async () => {
|
|
const { 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 }),
|
|
));
|
|
|
|
await scenarios.send('test');
|
|
expect(onNotif).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('store node fires onStore', async () => {
|
|
const { scenarios } = await createClientWithScenario();
|
|
const onStore = vi.fn();
|
|
scenarios.onStore = onStore;
|
|
|
|
const plan = makePlan({
|
|
nodes: [makeNode('start', 'store')],
|
|
});
|
|
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);
|
|
});
|
|
|
|
it('wait node fires onWait', async () => {
|
|
const { 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 }),
|
|
));
|
|
|
|
await scenarios.send('test');
|
|
expect(onWait).toHaveBeenCalledOnce();
|
|
expect(onWait.mock.calls[0][0]).toBeInstanceOf(WaitSession);
|
|
});
|
|
|
|
it('leaderboard node fires onLeaderboard', async () => {
|
|
const { 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 }),
|
|
));
|
|
|
|
await scenarios.send('test');
|
|
expect(onLb).toHaveBeenCalledOnce();
|
|
expect(onLb.mock.calls[0][0]).toBeInstanceOf(LeaderboardSession);
|
|
});
|
|
|
|
it('quest node dispatches onQuest instead of stalling', 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 }),
|
|
));
|
|
|
|
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);
|
|
});
|
|
|
|
it('battlepass node dispatches onBattlePass', async () => {
|
|
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 }),
|
|
));
|
|
|
|
await scenarios.send('test');
|
|
expect(onBp).toHaveBeenCalledOnce();
|
|
expect(scenarios.isRunning).toBe(true);
|
|
});
|
|
|
|
it('battlepass_level node dispatches onBattlePassLevel', async () => {
|
|
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 }),
|
|
));
|
|
|
|
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 }),
|
|
));
|
|
|
|
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();
|
|
|
|
const onNotif = vi.fn();
|
|
scenarios.onNotification = onNotif;
|
|
|
|
// 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 }));
|
|
}
|
|
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
|
|
}));
|
|
|
|
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).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
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')],
|
|
});
|
|
|
|
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();
|
|
|
|
const onCompleted = vi.fn();
|
|
const onRunCompleted = vi.fn();
|
|
scenarios.onCompleted = onCompleted;
|
|
scenarios.onRunCompleted = onRunCompleted;
|
|
|
|
const plan = makePlan({
|
|
nodes: [makeNode('start', 'notification')],
|
|
});
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('wait nodes', () => {
|
|
it('sets a deadline and fires onWait', async () => {
|
|
const { scenarios } = await createClientWithScenario();
|
|
const onWait = vi.fn();
|
|
scenarios.onWait = onWait;
|
|
|
|
const plan = makePlan({
|
|
nodes: [makeNode('start', 'wait', { duration: 10, unit: 'minutes' })],
|
|
});
|
|
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);
|
|
});
|
|
|
|
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;
|
|
|
|
// Duration of 0 should result in an immediate completion.
|
|
const plan = makePlan({
|
|
nodes: [makeNode('start', 'wait', { duration: 0, unit: 'seconds' })],
|
|
});
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('store session', () => {
|
|
it('buy() purchases the selected offer and completes with onPurchase', async () => {
|
|
const { scenarios } = await createClientWithScenario();
|
|
const onStore = vi.fn();
|
|
scenarios.onStore = 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 }));
|
|
}
|
|
if (init?.method === 'GET' && parsed.pathname === '/sdk/v1/stores/starter') {
|
|
return Promise.resolve(new Response(JSON.stringify({
|
|
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 }));
|
|
}
|
|
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
|
|
}));
|
|
|
|
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]);
|
|
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]);
|
|
expect(duplicate.success).toBe(false);
|
|
});
|
|
|
|
it('decline() completes with onDecline', async () => {
|
|
const { scenarios } = await createClientWithScenario();
|
|
const onStore = vi.fn();
|
|
scenarios.onStore = onStore;
|
|
|
|
const plan = makePlan({
|
|
nodes: [makeNode('start', 'store')],
|
|
});
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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 }),
|
|
));
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|