Initial commit
CI / check (push) Successful in 56s

This commit is contained in:
rudder
2026-08-12 14:02:25 +03:00
commit 3ab2d4a6cf
85 changed files with 10257 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createTestClient } from './helpers/createClient.js';
import { RudderNetworkError, RudderAuthError } from '../src/client/RudderError.js';
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
describe('AuthService', () => {
beforeEach(() => {
// Mock crypto.randomUUID for deterministic device ID.
vi.stubGlobal('crypto', {
randomUUID: () => '00000000-0000-4000-a000-000000000001',
});
localStorage.clear();
});
it('loginWithDevice sends correct request body', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
}));
});
vi.stubGlobal('fetch', fetchMock);
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
expect(fetchMock).toHaveBeenCalledTimes(8);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/sdk/v1/authorization/device');
const body = JSON.parse(init.body);
expect(body.key).toBe('test-project-key');
expect(body.deviceId).toBe('00000000-0000-4000-a000-000000000001');
expect(body.region).toBe('us');
expect(body.language).toBe('en');
expect(response.accessToken).toBe('access-token-123');
expect(response.refreshToken).toBe('refresh-token-456');
expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
'/sdk/v1/authorization/device',
'/sdk/v1/remote-configs',
'/sdk/v1/player/information',
'/sdk/v1/stores',
'/sdk/v1/catalog',
'/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',
]);
});
it('loginWithDevice saves tokens on success', async () => {
const client = createTestClient();
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({
accessToken: 'at',
refreshToken: 'rt',
}));
}));
await client.auth.loginWithDevice();
expect(client.options.tokenStore.getAccessToken()).toBe('at');
expect(client.options.tokenStore.getRefreshToken()).toBe('rt');
});
it('loginWithDevice throws RudderNetworkError on network failure', async () => {
const client = createTestClient();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Offline')));
await expect(client.auth.loginWithDevice()).rejects.toThrow(RudderNetworkError);
});
it('loginWithDevice throws RudderAuthError on 401', async () => {
const client = createTestClient();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }),
));
await expect(client.auth.loginWithDevice()).rejects.toThrow(RudderAuthError);
});
it('logout clears tokens', () => {
const client = createTestClient();
client.options.tokenStore.saveTokens('at', 'rt');
client.auth.logout();
expect(client.options.tokenStore.getAccessToken()).toBeNull();
expect(client.options.tokenStore.getRefreshToken()).toBeNull();
});
it('loginWithDevice sends token in Authorization header after login', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at2',
refreshToken: 'rt2',
}));
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
// Subsequent authenticated calls should include the token.
const response = await client.player.reload();
expect(response).toEqual({ player: null, wallets: [] });
const playerCall = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/sdk/v1/player/information'),
);
const headers = playerCall?.[1].headers;
expect(headers['Authorization']).toBe('Bearer at2');
});
it('loginWithDevice only fires player_login from the client runtime', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at-new',
refreshToken: 'rt-new',
}));
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(jsonResponse({ plans: [] }));
}
if (url.includes('/sdk/v1/stores')) {
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
const triggerBodies = fetchMock.mock.calls
.filter(([url]) => String(url).includes('/sdk/v1/scenarios/trigger'))
.map(([, init]) => JSON.parse(init.body));
expect(triggerBodies.map((body) => body.event)).toEqual(['player_login']);
});
it('loginWithDevice isolates post-login scenario trigger failures', async () => {
const client = createTestClient();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at',
refreshToken: 'rt',
}));
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(new Response('scenario failed', { status: 500, statusText: 'Internal Server Error' }));
}
if (url.includes('/sdk/v1/stores')) {
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
}));
await expect(client.auth.loginWithDevice()).resolves.toMatchObject({ accessToken: 'at' });
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('loginWithDevice defaults to region global and language en', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
const [, init] = fetchMock.mock.calls[0];
const body = JSON.parse(init.body);
expect(body.region).toBe('global');
expect(body.language).toBe('en');
});
});
describe('auth state', () => {
beforeEach(() => {
vi.stubGlobal('crypto', {
randomUUID: () => '00000000-0000-4000-a000-000000000001',
});
localStorage.clear();
});
function stubLoginFlow(): void {
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(jsonResponse({ plans: [] }));
}
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
}));
}
it('onAuthStateChange fires immediately, then on login and logout', async () => {
const client = createTestClient();
stubLoginFlow();
const states: string[] = [];
const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state));
expect(states).toEqual(['signed-out']);
expect(client.auth.isAuthenticated).toBe(false);
await client.auth.loginWithDevice();
expect(states).toEqual(['signed-out', 'signed-in']);
expect(client.auth.isAuthenticated).toBe(true);
client.auth.logout();
expect(states).toEqual(['signed-out', 'signed-in', 'signed-out']);
expect(client.auth.isAuthenticated).toBe(false);
unsubscribe();
client.auth.logout();
expect(states).toHaveLength(3);
});
it('starts signed-in when the token store already holds a token', () => {
const client = createTestClient();
client.options.tokenStore.saveTokens('at', 'rt');
const states: string[] = [];
client.auth.onAuthStateChange((state) => states.push(state));
expect(states).toEqual(['signed-in']);
});
it('emits signed-out when the transport session expires (refresh fails)', async () => {
const client = createTestClient();
client.options.tokenStore.saveTokens('expired-at', 'expired-rt');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }),
));
const states: string[] = [];
client.auth.onAuthStateChange((state) => states.push(state));
expect(states).toEqual(['signed-in']);
await expect(client.player.reload()).rejects.toThrow(RudderAuthError);
expect(states).toEqual(['signed-in', 'signed-out']);
expect(client.auth.isAuthenticated).toBe(false);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import * as sdk from '../src/index.js';
import { RudderClient } from '../src/index.js';
describe('public API surface', () => {
it('does not expose scenario runtime types from the root package', () => {
expect('ScenarioService' in sdk).toBe(false);
expect('NotificationSession' in sdk).toBe(false);
expect('StoreSession' in sdk).toBe(false);
expect('WaitSession' in sdk).toBe(false);
expect('LeaderboardSession' in sdk).toBe(false);
expect('PlanRun' in sdk).toBe(false);
expect('createIndexedDbPlanStateStore' in sdk).toBe(false);
});
it('keeps helpers, domain classes, and service constructors out of the barrel', () => {
// Device id helper — internal.
expect('getOrCreateDeviceId' in sdk).toBe(false);
// Domain classes — exported as types only.
for (const name of [
'PlayerDomain',
'CatalogDomain',
'InventoryDomain',
'ConfigDomain',
'StorageDomain',
'StoresDomain',
]) {
expect(name in sdk).toBe(false);
}
// Service constructors — exported as types only.
for (const name of [
'AuthService',
'LeaderboardsService',
'LeaderboardHandle',
'BattlePassService',
'BattlepassService',
'QuestsService',
]) {
expect(name in sdk).toBe(false);
}
});
it('exposes the expected value exports', () => {
for (const name of [
'RudderClient',
'RudderError',
'RudderNetworkError',
'RudderHttpError',
'RudderAuthError',
'SDK_ERROR_INVALID_OPTIONS',
'RudderErrorCodes',
'createDefaultTokenStore',
'createLocalStorageTokenStore',
'SyncedState',
'RemoteConfigState',
'ShopHandle',
'OfferHandle',
]) {
expect(name in sdk, name).toBe(true);
}
});
it('exposes effects without a public scenarios service on the client', () => {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
runtime: { planStateStore: null, loginEvent: null },
});
expect(client.effects).toBeDefined();
expect('scenarios' in client).toBe(false);
});
it('uses the canonical glossary property names on the client', () => {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
runtime: { planStateStore: null, loginEvent: null },
});
expect(client.remoteConfig).toBeDefined();
expect('config' in client).toBe(false);
expect(client.battlePass).toBeDefined();
expect('battlepass' in client).toBe(false);
expect(typeof client.effects.onBattlePass).toBe('function');
expect(typeof client.effects.onBattlePassLevel).toBe('function');
expect('onBattlepass' in client.effects).toBe(false);
expect('onBattlepassLevel' in client.effects).toBe(false);
});
});
+217
View File
@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RudderClient } from '../src/client/RudderClient.js';
import {
RudderError,
RudderNetworkError,
RudderHttpError,
RudderAuthError,
SDK_ERROR_INVALID_OPTIONS,
} from '../src/client/RudderError.js';
import { createFakeTokenStore } from './helpers/FakeTokenStore.js';
describe('RudderClient', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('constructs with valid options', () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
tokenStore: createFakeTokenStore(),
});
expect(client.auth).toBeDefined();
});
it('throws RudderError with sdk/invalid-options if baseUrl is missing', () => {
let caught: unknown;
try {
new RudderClient({ baseUrl: '', projectKey: 'proj_123' });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(RudderError);
expect((caught as RudderError).code).toBe(SDK_ERROR_INVALID_OPTIONS);
expect((caught as Error).message).toContain('baseUrl');
});
it('throws RudderError with sdk/invalid-options if projectKey is missing', () => {
let caught: unknown;
try {
new RudderClient({ baseUrl: 'https://api.example.com', projectKey: '' });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(RudderError);
expect((caught as RudderError).code).toBe(SDK_ERROR_INVALID_OPTIONS);
expect((caught as Error).message).toContain('projectKey');
});
it('creates a default localStorage-backed token store when tokenStore is omitted', () => {
localStorage.clear();
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
});
client.options.tokenStore.saveTokens('at', 'rt');
expect(localStorage.getItem('rudder_access_token')).toBe('at');
expect(client.options.tokenStore.getAccessToken()).toBe('at');
client.options.tokenStore.clear();
});
it('falls back to an in-memory token store when localStorage is unavailable', () => {
vi.stubGlobal('localStorage', undefined);
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
});
client.options.tokenStore.saveTokens('at', 'rt');
expect(client.options.tokenStore.getAccessToken()).toBe('at');
expect(client.options.tokenStore.getRefreshToken()).toBe('rt');
client.options.tokenStore.clear();
expect(client.options.tokenStore.getAccessToken()).toBeNull();
});
});
describe('transport error mapping', () => {
let client: RudderClient;
beforeEach(() => {
client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
tokenStore: createFakeTokenStore(),
});
});
it('throws RudderNetworkError with the underlying error as cause', async () => {
const underlying = new Error('Network down');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(underlying));
let caught: unknown;
await client.player.reload().catch((error) => {
caught = error;
});
expect(caught).toBeInstanceOf(RudderNetworkError);
expect((caught as RudderNetworkError).cause).toBe(underlying);
});
it('throws RudderAuthError on 401', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }),
));
await expect(client.player.reload())
.rejects.toThrow(RudderAuthError);
});
it('throws RudderHttpError with the server error code on non-401 HTTP error', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 'run_expired', error: 'gone' }), {
status: 410,
statusText: 'Gone',
}),
));
let caught: unknown;
await client.player.reload().catch((error) => {
caught = error;
});
expect(caught).toBeInstanceOf(RudderHttpError);
expect((caught as RudderHttpError).code).toBe('run_expired');
});
it('returns undefined for 204 No Content', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(null, { status: 204, statusText: 'No Content' }),
));
const result = await client.storage.save([]);
expect(result).toBeUndefined();
});
it('returns parsed JSON for 200 responses', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ player: { id: 'player-1' }, wallets: [] }), { status: 200 }),
));
const result = await client.player.reload();
expect(result).toEqual({ player: { id: 'player-1' }, wallets: [] });
});
});
describe('lazy scenario runtime', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('does not create the scenario runtime until first use', async () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
runtime: { planStateStore: null, loginEvent: null },
});
const internals = client as unknown as {
scenarioRuntime: unknown;
ensureScenarioRuntime(): Promise<unknown>;
};
expect(internals.scenarioRuntime).toBeNull();
await internals.ensureScenarioRuntime();
expect(internals.scenarioRuntime).not.toBeNull();
});
it('delivers effects to handlers subscribed before the runtime is ready', async () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
runtime: { planStateStore: null }, // default loginEvent: player_login
});
const notifications: string[] = [];
client.effects.onNotification((effect) => {
notifications.push(effect.message);
return effect.done();
});
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = new URL(url).pathname;
if (path === '/sdk/v1/authorization/device') {
return Promise.resolve(new Response(
JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }),
{ status: 200 },
));
}
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',
runId: 'run-1',
nodes: [{ id: 'start', type: 'notification', data: { message: 'Welcome!' } }],
edges: [],
boundaryNodes: [],
}],
}), { status: 200 }));
}
if (path === '/sdk/v1/remote-configs') {
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
}
if (path === '/sdk/v1/stores') {
return Promise.resolve(new Response(JSON.stringify({ stores: [], total: 0 }), { status: 200 }));
}
if (path === '/sdk/v1/catalog') {
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200 }));
}
if (path === '/sdk/v1/sync') {
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
}
void init;
return Promise.resolve(new Response(JSON.stringify({ player: null, wallets: [] }), { status: 200 }));
}));
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(notifications).toEqual(['Welcome!']));
client.dispose();
});
});
+528
View File
@@ -0,0 +1,528 @@
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', { level: 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);
});
});
});
+77
View File
@@ -0,0 +1,77 @@
// Minimal fetch wrapper for the LiveOps platform/admin HTTP API used by the seed
// and by tests' admin-side assertions. Retries network errors and 5xx with backoff;
// fails fast on 4xx.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly path: string,
public readonly body: string,
) {
super(`HTTP ${status} on ${path}: ${body.slice(0, 400)}`);
this.name = 'ApiError';
}
}
export interface AdminClient {
setToken(token: string): void;
readonly token: string | undefined;
get<T>(path: string, opts?: { auth?: boolean }): Promise<T>;
post<T>(path: string, body?: unknown, opts?: { auth?: boolean }): Promise<T>;
put<T>(path: string, body?: unknown, opts?: { auth?: boolean }): Promise<T>;
del<T>(path: string, opts?: { auth?: boolean }): Promise<T>;
}
export function createAdminClient(baseUrl: string): AdminClient {
let token: string | undefined;
async function request<T>(
method: string,
path: string,
body?: unknown,
opts?: { auth?: boolean },
): Promise<T> {
const url = `${baseUrl}${path}`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (opts?.auth !== false && token) headers['Authorization'] = `Bearer ${token}`;
let lastErr: unknown;
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
if (res.status >= 500) {
lastErr = new ApiError(res.status, path, text);
await sleep(300 * (attempt + 1));
continue;
}
if (!res.ok) throw new ApiError(res.status, path, text);
return (text ? JSON.parse(text) : undefined) as T;
} catch (err) {
if (err instanceof ApiError) throw err; // 4xx — don't retry
lastErr = err; // network error — retry
await sleep(300 * (attempt + 1));
}
}
throw lastErr;
}
return {
setToken(t: string) {
token = t;
},
get token() {
return token;
},
get: (path, opts) => request('GET', path, undefined, opts),
post: (path, body, opts) => request('POST', path, body, opts),
put: (path, body, opts) => request('PUT', path, body, opts),
del: (path, opts) => request('DELETE', path, undefined, opts),
};
}
+19
View File
@@ -0,0 +1,19 @@
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { SeedArtifact } from './types.js';
export const ARTIFACT_PATH = resolve(process.cwd(), '.e2e-prod.local.json');
export function writeArtifact(artifact: SeedArtifact): void {
writeFileSync(ARTIFACT_PATH, JSON.stringify(artifact, null, 2));
}
export function readArtifact(): SeedArtifact | null {
if (!existsSync(ARTIFACT_PATH)) return null;
return JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8')) as SeedArtifact;
}
export function removeArtifact(): void {
if (existsSync(ARTIFACT_PATH)) rmSync(ARTIFACT_PATH);
}
+48
View File
@@ -0,0 +1,48 @@
// Vitest globalSetup for the production e2e suite.
//
// Setup: seeds a fresh project on prod (unless RUDDER_E2E_PROJECT_KEY is provided) and
// writes .e2e-prod.local.json for the tests to read.
// Teardown: deletes the seeded project (default). Set RUDDER_E2E_KEEP=1 to keep it.
import { runSeed, deleteProject } from './seed.js';
import { readArtifact, writeArtifact, removeArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js';
export default async function setup(): Promise<() => Promise<void>> {
const baseUrl = process.env.RUDDER_E2E_BASE_URL ?? 'https://api.rudder.build';
// Externally-provided project: skip seeding, just verify an artifact/env exists.
if (process.env.RUDDER_E2E_PROJECT_KEY) {
const existing = readArtifact();
if (!existing) {
writeArtifact({
baseUrl,
projectKey: process.env.RUDDER_E2E_PROJECT_KEY,
// Remaining fields are unknown for an external project; tests relying on the
// seeded dataset/admin token will require a real seed run.
} as unknown as SeedArtifact);
}
return async () => {};
}
const log = (m: string) => console.log(`[seed] ${m}`);
log(`seeding ${baseUrl} ...`);
const artifact = await runSeed(baseUrl, log);
writeArtifact(artifact);
log(`seed complete: project=${artifact.projectId}`);
return async () => {
if (process.env.RUDDER_E2E_KEEP === '1') {
console.log('[seed] RUDDER_E2E_KEEP=1 — leaving project in place');
return;
}
try {
await deleteProject(artifact);
console.log(`[seed] deleted project ${artifact.projectId}`);
} catch (err) {
console.warn(`[seed] failed to delete project ${artifact.projectId}: ${String(err)}`);
} finally {
removeArtifact();
}
};
}
+99
View File
@@ -0,0 +1,99 @@
// Test-side helpers: load the seed artifact, build SDK clients against prod, and
// reach the admin API for wallet grants / player inspection.
import { RudderClient } from '../../../src/client/RudderClient.js';
import type { RemoteConfigShape } from '../../../src/state/RemoteConfigState.js';
import type { TokenStore } from '../../../src/token/TokenStore.js';
import { createAdminClient, type AdminClient } from './adminClient.js';
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;
};
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
export function loadArtifact(): SeedArtifact {
const file = readArtifact();
if (!file) {
throw new Error(
'No seed artifact (.e2e-prod.local.json). Run `npm run test:e2e-prod` (globalSetup seeds automatically).',
);
}
return {
...file,
baseUrl: process.env.RUDDER_E2E_BASE_URL ?? file.baseUrl,
projectKey: process.env.RUDDER_E2E_PROJECT_KEY ?? file.projectKey,
};
}
export function createInMemoryTokenStore(): TokenStore {
let access: string | null = null;
let refresh: string | null = null;
return {
getAccessToken: () => access,
getRefreshToken: () => refresh,
saveTokens: (a, r) => {
access = a;
refresh = r;
},
clear: () => {
access = null;
refresh = null;
},
};
}
/** 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 },
): RudderClient<TConfig> {
return new RudderClient<TConfig>({
baseUrl: artifact.baseUrl,
projectKey: artifact.projectKey,
tokenStore: createInMemoryTokenStore(),
runtime,
});
}
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
export async function freshPlayer<TConfig extends RemoteConfigShape = RemoteConfigShape>(
artifact: SeedArtifact = loadArtifact(),
runtime?: RuntimeOptions,
): Promise<RudderClient<TConfig>> {
const client = makeProdClient<TConfig>(artifact, runtime);
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
return client;
}
/** Admin (operator) API client authenticated with the seeded operator token. */
export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient {
const api = createAdminClient(artifact.baseUrl);
api.setToken(artifact.adminToken);
return api;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */
export async function withReadRetry<T>(
fn: () => Promise<T>,
ok: (value: T) => boolean,
tries = 8,
gapMs = 1500,
): Promise<T> {
let last = await fn();
if (ok(last)) return last;
for (let i = 1; i < tries; i++) {
await sleep(gapMs);
last = await fn();
if (ok(last)) return last;
}
return last;
}
+311
View File
@@ -0,0 +1,311 @@
// Production seed: registers an operator, creates a project, configures staging
// content (items, remote configs, leaderboard, store with free+paid offers, a
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves.
// Excludes realtime & analytics.
//
// The scenario flow uses the SDK runtime's exact node `data` keys and handle names —
// the game's plan builder copies node.data verbatim into the execution plan
// (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs.
import { createAdminClient, type AdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.js';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
interface ReleaseResponse {
id: string;
status: string;
version: number;
}
interface QuestResponse {
id: string;
name: string;
status: string;
objectives?: Array<{ id: string; metric: string; target: number }>;
rewards?: Array<{ itemId?: string; currency?: string; amount: number }>;
}
interface StoreResponse {
id: string;
name: string;
data?: { slug?: string };
offers?: Array<{
id: string;
name: string;
contents?: Array<{ itemId: string; amount: number }>;
}>;
}
function buildFlow(scenarioSlug: string) {
return {
nodes: [
{ id: 'trigger_1', type: 'trigger', data: { triggerType: 'event', onEvent: 'session_start' } },
{ id: 'notify_1', type: 'notification', data: { title: 'Welcome', message: 'Welcome to the game!' } },
{
id: 'rc_1',
type: 'remote_config_override',
data: { patches: [{ path: 'spawn_rate', valueType: 'float', value: '3.0' }] },
},
{ id: 'store_1', type: 'store', data: { storeSlug: scenarioSlug } },
{ id: 'cond_1', type: 'condition', data: { logic: 'AND', rules: [] } },
{ id: 'notify_2', type: 'notification', data: { title: 'Thanks', message: 'Enjoy your purchase!' } },
],
edges: [
{ id: 'e1', source: 'trigger_1', sourceHandle: 'onActivate', target: 'notify_1' },
{ id: 'e2', source: 'notify_1', sourceHandle: 'output', target: 'rc_1' },
{ id: 'e3', source: 'rc_1', sourceHandle: 'output', target: 'store_1' },
{ id: 'e4', source: 'store_1', sourceHandle: 'onPurchase', target: 'cond_1' },
{ id: 'e5', source: 'cond_1', sourceHandle: 'true', target: 'notify_2' },
],
};
}
async function pollRelease(
api: AdminClient,
projPath: string,
env: string,
releaseId: string,
log: (m: string) => void,
): Promise<void> {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const releases = await api.get<ReleaseResponse[]>(
`${projPath}/releases?environment=${env}&limit=20&offset=0`,
);
const rel = releases.find((r) => r.id === releaseId);
if (rel) {
if (rel.status === 'completed') {
log(`release ${releaseId} completed (v${rel.version})`);
return;
}
if (rel.status === 'failed') {
throw new Error(`release ${releaseId} failed`);
}
}
await sleep(1500);
}
throw new Error(`release ${releaseId} did not complete within 60s`);
}
export async function runSeed(
baseUrl: string,
log: (m: string) => void = () => {},
): Promise<SeedArtifact> {
const api = createAdminClient(baseUrl);
const ts = Date.now();
const authoringEnv = 'staging';
const runtimeEnv = 'prod';
const q = `?environment=${authoringEnv}`;
const prodQ = `?environment=${runtimeEnv}`;
const adminEmail = `e2e+${ts}@rudder.build`;
const password = `Passw0rd!e2e-${ts}`;
// 1. Register operator (open registration; no approval gate at the API layer).
const auth = await api.post<{ accessToken: string; refreshToken: string }>(
'/platform/v1/authorization/register',
{ email: adminEmail, password, fullName: 'E2E Bot' },
{ auth: false },
);
api.setToken(auth.accessToken);
log(`registered ${adminEmail}`);
// 2. Project.
const project = await api.post<{ id: string; key: string; name: string }>(
'/platform/v1/projects',
{ name: `e2e-${ts}` },
);
const projPath = `/platform/v1/projects/${project.id}`;
log(`project ${project.id} key=${project.key}`);
// 3. Staging environment (idempotent — may already exist).
try {
await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv });
} catch {
// already exists / not required — content POSTs below are the real test
}
// 4. Item (granted as a paid offer's contents).
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Gold Coin',
tags: ['currency'],
});
// 5. Remote configs — one per value type.
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ key: 'max_energy', value: '100', valueType: 'int' },
{ key: 'spawn_rate', value: '1.5', valueType: 'float' },
{ key: 'feature_x', value: 'true', valueType: 'bool' },
{ key: 'welcome_text', value: 'hi', valueType: 'string' },
{ key: 'shop_layout', value: '{"cols":3}', valueType: 'json' },
];
for (const rc of remoteConfigs) {
await api.post(`${projPath}/remote-configs${q}`, {
projectId: project.id,
environment: authoringEnv,
...rc,
});
}
log(`created ${remoteConfigs.length} remote configs`);
// 6. Leaderboard.
await api.post(`${projPath}/leaderboards${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Weekly Score',
slug: 'weekly-score',
metric: 'score',
resetPeriod: 'weekly',
sortingOrder: 'desc',
maxEntries: 100,
});
// 7. Store with a free offer and a paid (currency) offer with a purchase limit.
// The runtime resolves a store's slug from data.slug (liveops-game shop cache), and
// the CreateStore API has no slug field — so we set it via data.
const storeSlug = 'starter-shop';
const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>(
`${projPath}/stores${q}`,
{
projectId: project.id,
environment: authoringEnv,
name: 'Starter Shop',
description: 'E2E shop',
data: { slug: storeSlug },
offers: [
{ name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency: 'soft', amount: 0 } },
{
name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }],
price: { currency: 'soft', amount: 100 },
maxPurchases: 2,
},
],
},
);
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack');
if (!paidOffer?.id) {
throw new Error('seeded paid offer did not return an id');
}
// 8. Global quest: buying the paid offer completes it; claim grants extra item reward.
const questName = 'Buy Gold Pack Quest';
const questObjectiveId = 'buy-gold-pack';
const questTarget = 1;
const questMetric = `purchase.offer:${paidOffer.id}`;
const questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id,
environment: authoringEnv,
name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }],
});
log(`quest ${quest.id} staged for metric ${questMetric}`);
// 9. Scenario (must be live now: startAt <= now <= endAt).
const startAt = new Date(ts - 3600_000).toISOString();
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Onboarding',
description: 'e2e onboarding',
startAt,
endAt,
});
// 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}`, {
projectId: project.id,
scenarioId: scenario.id,
flow: buildFlow(storeSlug),
});
log(`scenario ${scenario.id} flow set`);
// 11. Promote staging content to prod (the runtime serves prod/latest.json).
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`);
await pollRelease(api, projPath, runtimeEnv, release.id, log);
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop');
if (!prodStore) {
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
}
const prodPaidOffer = prodStore.offers?.find((o) => o.name === 'Gold Pack');
if (!prodPaidOffer?.id) {
throw new Error('promoted paid offer did not return an id');
}
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) {
throw new Error('promoted paid offer did not return a reward item id');
}
const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`);
const prodQuest = prodQuests.find((q) => q.name === questName);
if (!prodQuest?.id) {
throw new Error(`promoted quest ${questName} was not found in ${runtimeEnv}`);
}
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
if (!prodQuestMetric) {
throw new Error(`promoted quest ${questName} did not return metric ${questObjectiveId}`);
}
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
if (!prodQuestRewardItemId) {
throw new Error(`promoted quest ${questName} did not return reward item id`);
}
// 12. Settle: let the game service hot-reload caches from the new snapshot.
await sleep(3000);
return {
baseUrl,
projectId: project.id,
projectKey: project.key,
adminToken: auth.accessToken,
adminEmail,
environment: runtimeEnv,
seededAt: new Date(ts).toISOString(),
releaseId: release.id,
store: {
name: 'Starter Shop',
slug: storeSlug,
freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack',
paidOfferId: prodPaidOffer.id,
paidPrice: { currency: 'soft', amount: 100 },
paidMaxPurchases: 2,
grantedItemName: 'Gold Coin',
paidGrantAmount: 50,
},
quest: {
id: prodQuest.id,
name: questName,
objectiveId: questObjectiveId,
metric: prodQuestMetric,
target: questTarget,
rewardItemId: prodQuestRewardItemId,
rewardAmount: questRewardAmount,
},
leaderboard: { slug: 'weekly-score', metric: 'score' },
item: { id: prodRewardItemId, name: item.name },
remoteConfigs: {
maxEnergy: 100,
spawnRate: 1.5,
featureX: true,
welcomeText: 'hi',
shopLayout: { cols: 3 },
spawnRateOverride: 3.0,
},
scenario: { id: scenario.id, event: 'session_start' },
};
}
export async function deleteProject(artifact: SeedArtifact): Promise<void> {
const api = createAdminClient(artifact.baseUrl);
api.setToken(artifact.adminToken);
await api.del(`/platform/v1/projects/${artifact.projectId}`);
}
+46
View File
@@ -0,0 +1,46 @@
// Shared types for the production e2e harness.
/** Persisted seed output, written to .e2e-prod.local.json and read by tests. */
export interface SeedArtifact {
baseUrl: string;
projectId: string;
projectKey: string;
/** Operator (platform) bearer token — used for admin calls (wallet grant, player details). */
adminToken: string;
adminEmail: string;
environment: string;
seededAt: string;
releaseId: string;
store: {
name: string;
slug: string;
freeOfferName: string;
paidOfferName: string;
paidOfferId: string;
paidPrice: { currency: string; amount: number };
paidMaxPurchases: number;
grantedItemName: string;
paidGrantAmount: number;
};
quest: {
id: string;
name: string;
objectiveId: string;
metric: string;
target: number;
rewardItemId: string;
rewardAmount: number;
};
leaderboard: { slug: string; metric: string };
item: { id: string; name: string };
remoteConfigs: {
maxEnergy: number;
spawnRate: number;
featureX: boolean;
welcomeText: string;
shopLayout: Record<string, unknown>;
/** spawn_rate value the scenario's remote_config_override applies. */
spawnRateOverride: number;
};
scenario: { id: string; event: string };
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { loadArtifact } from './_setup/harness.js';
// Smoke gate: if egress to prod is blocked or the gateway is down, fail fast here.
describe('e2e-prod: health', () => {
let baseUrl: string;
beforeAll(() => {
baseUrl = loadArtifact().baseUrl;
});
it('GET /health is ok', async () => {
const res = await fetch(`${baseUrl}/health`);
expect(res.ok).toBe(true);
const body = (await res.json()) as { status?: string };
expect(body.status).toBe('ok');
});
it('content + game gRPC backends are serving', async () => {
// /readyz may be 503 if analytics is not_serving (out of scope here); assert the
// backends this suite actually uses are serving.
const res = await fetch(`${baseUrl}/readyz`);
const body = (await res.json()) as { grpc?: Record<string, string> };
expect(body.grpc?.content).toBe('serving');
expect(body.grpc?.game).toBe('serving');
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
describe('e2e-prod: leaderboards', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('submits a score and sees it in the ranking', async () => {
const client = await freshPlayer(artifact);
const me = (await client.player.reload()).player?.id;
expect(me).toBeTruthy();
const lb = client.leaderboards.findBySlug(artifact.leaderboard.slug);
const score = 4242;
await lb.submit(score);
const entries = await withReadRetry(
() => lb.list(50),
(list) => list.some((e) => e.playerId === me),
);
const mine = entries.find((e) => e.playerId === me);
expect(mine).toBeDefined();
// int64 fields (score, rank) serialize as JSON strings via protojson — coerce to number.
expect(Number(mine?.score)).toBe(score);
expect(Number(mine?.rank ?? 0)).toBeGreaterThanOrEqual(1);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest';
import { freshPlayer } from './_setup/harness.js';
describe('e2e-prod: player', () => {
it('device login yields a profile with a player id and wallets array', async () => {
const client = await freshPlayer();
const profile = await client.player.reload();
expect(profile.player?.id).toBeTruthy();
expect(Array.isArray(profile.wallets)).toBe(true);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { adminApi, freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { RudderClient } from '../../src/client/RudderClient.js';
import type { OfferHandle } from '../../src/state/shops.js';
// Exercises the (newly implemented) wallet + inventory + purchase backend end-to-end:
// admin grant -> debit on buy -> inventory credit -> idempotency -> max_purchases -> insufficient funds.
interface AdminWalletDetails {
currencyCode: string;
balance: number;
}
interface AdminInventoryItem {
itemId: string;
amount: number;
}
interface AdminPlayerDetails {
wallets?: AdminWalletDetails[];
inventory?: AdminInventoryItem[];
}
describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
async function resolvePaidOffer(client: RudderClient): Promise<OfferHandle> {
const stores = await withReadRetry(
() => client.stores.reload(),
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
);
const store = stores.find((s) => s.name === artifact.store.name)!;
return store.offers.find((o) => o.name === artifact.store.paidOfferName)!;
}
async function balance(client: RudderClient, currency: string): Promise<number> {
const profile = await client.player.reload();
// int64 fields serialize as JSON strings via protojson — coerce to number.
return Number(profile.wallets?.find((w) => w.currency === currency)?.balance ?? 0);
}
it('grants currency, debits on buy, credits inventory, is idempotent, and enforces the purchase limit', async () => {
const client = await freshPlayer(artifact);
const playerId = (await client.player.reload()).player!.id!;
const api = adminApi(artifact);
const currency = artifact.store.paidPrice.currency;
const price = artifact.store.paidPrice.amount;
const offer = await resolvePaidOffer(client);
// 1. Grant currency via the new admin endpoint.
await api.post(`/game/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, {
currencyCode: currency,
amount: 1000,
reason: 'e2e grant',
});
const granted = await withReadRetry(
() => balance(client, currency),
(b) => b === 1000,
);
expect(granted).toBe(1000);
// 2. First paid buy: success + wallet debited.
const buy1 = await offer.buy();
expect(buy1.success).toBe(true);
expect(buy1.purchaseId).toBeTruthy();
expect(await balance(client, currency)).toBe(1000 - price);
// 3. Inventory credited (verified via admin player details).
const details = await api.get<AdminPlayerDetails>(
`/game/v1/projects/${artifact.projectId}/players/${playerId}`,
);
const inv = details.inventory?.find((i) => i.itemId === artifact.item.id);
expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount);
// 4. Idempotency: the same key twice charges once.
const key = crypto.randomUUID();
const a = await offer.buy({ idempotencyKey: key });
const b = await offer.buy({ idempotencyKey: key });
expect(a.success).toBe(true);
expect(b.success).toBe(true);
expect(await balance(client, currency)).toBe(1000 - price * 2);
// 5. max_purchases (2) reached -> a new distinct buy fails (no charge).
const overLimit = await offer.buy();
expect(overLimit.success).toBe(false);
expect(overLimit.error ?? '').toContain('limit');
expect(await balance(client, currency)).toBe(1000 - price * 2);
});
it('rejects a paid purchase with insufficient funds', async () => {
const poor = await freshPlayer(artifact);
const offer = await resolvePaidOffer(poor);
const res = await offer.buy();
expect(res.success).toBe(false);
expect(res.error ?? '').toContain('insufficient');
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
interface GameRemoteConfig extends Record<string, unknown> {
max_energy: number;
spawn_rate: number;
feature_x: boolean;
welcome_text: string;
shop_layout: Record<string, unknown>;
}
describe('e2e-prod: remoteConfig', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('loads typed configs matching the seeded values', async () => {
const rc = artifact.remoteConfigs;
const client = await withReadRetry(
() => freshPlayer<GameRemoteConfig>(artifact),
(candidate) => candidate.remoteConfig.get('max_energy', 0) === rc.maxEnergy,
);
expect(client.remoteConfig.status).toBe('ready');
expect(client.remoteConfig.get('max_energy', 0)).toBe(rc.maxEnergy);
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(rc.spawnRate);
expect(client.remoteConfig.get('feature_x', false)).toBe(rc.featureX);
expect(client.remoteConfig.get('welcome_text', '')).toBe(rc.welcomeText);
expect(client.remoteConfig.get('shop_layout', {} as Record<string, unknown>)).toEqual(rc.shopLayout);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { loadArtifact, makeProdClient } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
import { getScenarioRuntime } from '../../src/client/RudderClient.js';
describe('e2e-prod: scenarios', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('runs onboarding: notification -> rc override -> store -> boundary callback -> notification', async () => {
const notifications: NotificationEffect[] = [];
let storeOffer: StoreOfferEffect | undefined;
let runCompleted = false;
const client = makeProdClient(artifact, {
planStateStore: null,
loginEvent: artifact.scenario.event,
});
client.effects.onNotification((effect) => { notifications.push(effect); });
client.effects.onStoreOffer((effect) => {
storeOffer = effect;
});
const runtime = await getScenarioRuntime(client);
runtime.onRunCompleted = () => {
runCompleted = true;
};
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
// First notification dispatched.
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 });
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(
artifact.remoteConfigs.spawnRateOverride,
);
// Buy crosses the server boundary (POST /sdk/v1/scenarios/callback) -> second notification.
const offer = storeOffer!.offers.find((o) => o.name === artifact.store.paidOfferName)!;
await storeOffer!.buy(offer);
await vi.waitFor(() => expect(notifications.length).toBe(2), { timeout: 15_000 });
await notifications[1].done();
await vi.waitFor(() => expect(runtime.isRunning).toBe(false), { timeout: 10_000 });
expect(runCompleted).toBe(true);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { freshPlayer } from './_setup/harness.js';
describe('e2e-prod: storage', () => {
it('save -> get -> delete round-trip', async () => {
const client = await freshPlayer();
const payload = JSON.stringify({ level: 7 });
const item = { id: 'slot_1', type: 'savegame', data: payload };
await client.storage.save([item]);
// The server may assign its own item id, so match on type + data, not the client id.
// v0.2 fetches the whole storage; filtering by type is client-side.
const got = await client.storage.reload();
expect(got.items?.some((i) => i.type === 'savegame' && i.data === payload)).toBe(true);
await client.storage.delete('savegame');
const after = await client.storage.reload();
expect(after.items?.some((i) => i.data === payload)).toBeFalsy();
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
describe('e2e-prod: stores', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('lists the seeded store with its free + paid offers', async () => {
const client = await freshPlayer(artifact);
const stores = await withReadRetry(
() => client.stores.reload(),
(r) => r.length > 0,
);
const store = stores.find((s) => s.name === artifact.store.name);
expect(store).toBeDefined();
const offerNames = store!.offers.map((o) => o.name);
expect(offerNames).toContain(artifact.store.freeOfferName);
expect(offerNames).toContain(artifact.store.paidOfferName);
});
it('buys the free offer successfully', async () => {
const client = await freshPlayer(artifact);
const stores = await withReadRetry(
() => client.stores.reload(),
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
);
const store = stores.find((s) => s.name === artifact.store.name)!;
const free = store.offers.find((o) => o.name === artifact.store.freeOfferName)!;
const res = await free.buy();
expect(res.success).toBe(true);
expect(res.purchaseId).toBeTruthy();
});
});
+159
View File
@@ -0,0 +1,159 @@
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 { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } 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;
beforeEach(() => {
stubDeterministicUuid();
gateway = createFakeGateway({
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
}],
});
gateway.install();
client = makeClient();
});
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 messages: string[] = [];
const completed: PlanRun[] = [];
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);
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?.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']);
});
it('decline stays local → no callback, local edge is followed', async () => {
gateway.onEvent('player_login', offerWithBoundary());
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!.dismiss();
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(false);
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());
gateway.onCallbackError('offer', 'onPurchase', 500);
const failures: ScenarioRunFailedEvent[] = [];
const completed: PlanRun[] = [];
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);
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(runtime.isRunning).toBe(true);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
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 { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
const MINUTE = 60_000;
function makeClient(): RudderClient {
return new 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();
}
describe('E2E: player offer journey', () => {
let gateway: FakeGateway;
let client: RudderClient;
beforeEach(() => {
stubDeterministicUuid();
vi.useFakeTimers();
gateway = createFakeGateway({
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
}],
});
gateway.install();
client = makeClient();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('authenticates the device and persists tokens for later calls', async () => {
const res = await client.auth.loginWithDevice({ region: 'eu', language: 'en' });
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 notifications: NotificationEffect[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { notifications.push(effect); });
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);
expect(notifications).toHaveLength(1);
expect(notifications[0].message).toBe('Your offer is still available!');
});
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
gateway.onEvent('player_login', offerScenario());
let offer: StoreOfferEffect | undefined;
const notifications: NotificationEffect[] = [];
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { notifications.push(effect); });
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 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.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.dismiss();
await vi.advanceTimersByTimeAsync(MINUTE);
expect(messages).toEqual(['Your offer is still available!']);
expect(messages).not.toContain('Thanks for your purchase!');
expect(gateway.purchases).toHaveLength(0);
});
});
+75
View File
@@ -0,0 +1,75 @@
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';
const MINUTE = 60_000;
describe('E2E: scenario state survives a reload', () => {
let gateway: FakeGateway;
// Shared backing store mimics IndexedDB persisting across page loads.
const backing = { value: null as string | null };
function clientWithSharedStore(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: createMemoryPlanStore(backing), 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.install();
});
afterEach(() => {
vi.useRealTimers();
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();
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; };
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!');
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
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> {
max_energy: number;
drop_rate: number;
pvp_enabled: boolean;
welcome_text: string;
boss: { hp: number; name: string };
legacy: string;
missing_declared: string;
}
function cfg(key: string, value: string, valueType: string, active = true): RemoteConfig {
return { key, value, valueType, active };
}
function makeClient(): RudderClient<GameRemoteConfig> {
return new RudderClient<GameRemoteConfig>({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null },
});
}
describe('E2E: remote config', () => {
let gateway: FakeGateway;
let client: RudderClient<GameRemoteConfig>;
beforeEach(() => {
stubDeterministicUuid();
gateway = createFakeGateway({
remoteConfigs: {
max_energy: cfg('max_energy', '50', 'int'),
drop_rate: cfg('drop_rate', '0.25', 'float'),
pvp_enabled: cfg('pvp_enabled', 'true', 'bool'),
welcome_text: cfg('welcome_text', 'Hello!', 'string'),
boss: cfg('boss', '{"hp":1000,"name":"Drake"}', 'json'),
legacy: cfg('legacy', 'old', 'string', false), // inactive — must be excluded
},
});
gateway.install();
client = makeClient();
});
afterEach(() => vi.unstubAllGlobals());
it('loads configs and reads them back with type coercion', async () => {
await client.auth.loginWithDevice();
expect(client.remoteConfig.status).toBe('ready');
expect(client.remoteConfig.get('max_energy', 0)).toBe(50);
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.25);
expect(client.remoteConfig.get('pvp_enabled', false)).toBe(true);
expect(client.remoteConfig.get('welcome_text', '')).toBe('Hello!');
expect(client.remoteConfig.get('boss', { hp: 0, name: '' })).toEqual({
hp: 1000,
name: 'Drake',
});
});
it('inactive configs are not exposed; missing keys fall back to default', async () => {
await client.auth.loginWithDevice();
expect(client.remoteConfig.get('legacy', 'DEFAULT')).toBe('DEFAULT');
expect(client.remoteConfig.get('missing_declared', 'fallback')).toBe('fallback');
});
it('get() before login returns the default (no throw)', () => {
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);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null },
});
}
describe('E2E: player storage', () => {
let gateway: FakeGateway;
let client: RudderClient;
beforeEach(() => {
gateway = createFakeGateway();
gateway.install();
client = makeClient();
});
afterEach(() => vi.unstubAllGlobals());
it('saves items then reads them back (round-trip through the gateway)', async () => {
await client.auth.loginWithDevice();
await client.storage.save([
{ id: 'save_1', type: 'progress', data: JSON.stringify({ level: 7, gold: 1200 }) },
{ id: 'settings', type: 'prefs', data: JSON.stringify({ sound: false }) },
]);
const all = await client.storage.reload();
expect(all.items).toHaveLength(2);
const progress = all.items!.find((i) => i.id === 'save_1');
expect(JSON.parse(progress!.data!)).toEqual({ level: 7, gold: 1200 });
});
it('filters by type', async () => {
await client.auth.loginWithDevice();
await client.storage.save([
{ id: 'a', type: 'progress', data: '{}' },
{ id: 'b', type: 'prefs', data: '{}' },
]);
// v0.2 fetches the whole storage; filtering by type is client-side.
const all = await client.storage.reload();
const prefs = (all.items ?? []).filter((i) => i.type === 'prefs');
expect(prefs).toHaveLength(1);
expect(prefs[0].id).toBe('b');
// The read went out as a storage fetch with limit as a query param, not a path segment.
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
expect(getReq?.query.get('limit')).toBe('100');
});
it('deletes a type and the items are gone', async () => {
await client.auth.loginWithDevice();
await client.storage.save([
{ id: 'a', type: 'progress', data: '{}' },
{ id: 'b', type: 'prefs', data: '{}' },
]);
await client.storage.delete('progress');
const remaining = await client.storage.reload();
expect(remaining.items?.map((i) => i.id)).toEqual(['b']);
});
it('storage writes carry the auth token', async () => {
const token = (await client.auth.loginWithDevice()).accessToken;
await client.storage.save([{ id: 'a', type: 't', data: '{}' }]);
const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage');
expect(put?.authToken).toBe(token);
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { TokenStore } from '../../src/token/TokenStore.js';
/** In-memory token store for testing. */
export function createFakeTokenStore(): TokenStore {
let access: string | null = null;
let refresh: string | null = null;
return {
getAccessToken: () => access,
getRefreshToken: () => refresh,
saveTokens: (a, r) => {
access = a;
refresh = r;
},
clear: () => {
access = null;
refresh = null;
},
};
}
+13
View File
@@ -0,0 +1,13 @@
import { RudderClient } from '../../src/client/RudderClient.js';
import type { RudderClientOptions } from '../../src/client/RudderClientOptions.js';
import { createFakeTokenStore } from './FakeTokenStore.js';
/** Creates a RudderClient with test-friendly defaults and a fake token store. */
export function createTestClient(overrides?: Partial<RudderClientOptions>): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project-key',
tokenStore: createFakeTokenStore(),
...overrides,
});
}
+241
View File
@@ -0,0 +1,241 @@
import { vi } from 'vitest';
import type { ExecutionPlan } from '../../src/generated/common.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';
import type {
ClaimQuestResponse,
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;
query: URLSearchParams;
body: unknown;
authToken: string | null;
}
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;
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;
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const noContent = (): Response => new Response(null, { status: 204 });
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,
remoteConfigs: seed?.remoteConfigs ?? {},
stores: seed?.stores ?? [],
quests: seed?.quests ?? { quests: [] },
questClaims: seed?.questClaims ?? new Map(),
storage: seed?.storage ?? new Map(),
};
const recorded: RecordedRequest[] = [];
const purchases: FakeGateway['purchases'] = [];
const questClaims: FakeGateway['questClaims'] = [];
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({
accessToken: `access-${b.deviceId ?? 'dev'}`,
refreshToken: `refresh-${b.deviceId ?? 'dev'}`,
});
}
// --- 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) ?? [] });
}
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
const b = body as { nodeId?: string; handle?: 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 } : {});
}
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
return json(state.counterPlan ? { plan: state.counterPlan, completed: false } : { completed: false });
}
// --- Remote config ---
if (method === 'GET' && path === '/sdk/v1/remote-configs') {
return json({ configs: state.remoteConfigs });
}
if (method === 'GET' && path.startsWith('/sdk/v1/remote-configs/')) {
const key = decodeURIComponent(path.slice('/sdk/v1/remote-configs/'.length));
const cfg = state.remoteConfigs[key];
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 });
}
const purchaseMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)\/offers\/([^/]+)\/purchase$/);
if (method === 'POST' && purchaseMatch) {
const b = body as { idempotencyKey?: string };
purchases.push({
storeSlug: decodeURIComponent(purchaseMatch[1]),
offerId: decodeURIComponent(purchaseMatch[2]),
idempotencyKey: b.idempotencyKey ?? '',
authToken: req.authToken,
});
return json({ success: true, purchaseId: `purchase-${purchases.length}` });
}
const storeMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)$/);
if (method === 'GET' && storeMatch) {
const slug = decodeURIComponent(storeMatch[1]);
const store = state.stores.find((s) => s.slug === slug);
return store ? json(store) : json({ error: 'not found' }, 404);
}
// --- Quests ---
if (method === 'POST' && path === '/sdk/v1/quests/list') {
return json(state.quests);
}
if (method === 'POST' && path === '/sdk/v1/quests/claim') {
const b = body as { questId?: string };
const questId = b.questId ?? '';
questClaims.push({ questId, authToken: req.authToken });
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');
const items = [...state.storage.values()].filter(
(it) => !typeFilter || it.type === typeFilter,
);
return json({ items, nextCursor: '' });
}
if (method === 'PUT') {
const items = (body as { items?: StorageItem[] }).items ?? [];
for (const item of items) {
if (item.id) state.storage.set(item.id, item);
}
return noContent();
}
if (method === 'DELETE') {
const type = query.get('type');
for (const [id, item] of state.storage) {
if (!type || item.type === type) state.storage.delete(id);
}
return noContent();
}
}
return json({ error: `unhandled route: ${method} ${path}` }, 500);
}
function install(): void {
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL, init?: RequestInit): Promise<Response> => {
const url = new URL(typeof input === 'string' ? input : input.toString());
const headers = new Headers(init?.headers);
const auth = headers.get('Authorization');
const req: RecordedRequest = {
method: (init?.method ?? 'GET').toUpperCase(),
path: url.pathname,
query: url.searchParams,
body: init?.body ? JSON.parse(init.body as string) : undefined,
authToken: auth ? auth.replace(/^Bearer\s+/, '') : null,
};
recorded.push(req);
return handle(req);
}),
);
}
return {
state,
recorded,
purchases,
questClaims,
install,
onEvent(event, ...plans) {
state.scenarios.set(event, plans);
},
onCallback(nodeId, handle, plan) {
state.callbacks.set(`${nodeId}:${handle}`, plan);
},
onCallbackError(nodeId, handle, status = 500) {
state.callbackErrors.set(`${nodeId}:${handle}`, status);
},
};
}
+41
View File
@@ -0,0 +1,41 @@
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);
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
...existing,
randomUUID: () => {
n += 1;
const hex = n.toString(16).padStart(12, '0');
return `00000000-0000-4000-a000-${hex}` as `${string}-${string}-${string}-${string}-${string}`;
},
},
});
}
+72
View File
@@ -0,0 +1,72 @@
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../src/generated/common.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);
}