Server-side scenario execution: thin effects client replaces local engine
- engine/ (DagWalker, sessions, IndexedDbPlanStore) deleted; server owns the graph - trigger/callback/counter carry PendingEffect; GET /sdk/v1/scenarios/pending polled (30s jittered heartbeat paused on hidden tab + wait-deadline timers) - client.effects public API unchanged (done/buy/dismiss/end/claim/battlepass/quest) - planStateStore removed; loginEvent kept; reconcile() makes server the source of truth - generated models regenerated; skills/README/CHANGELOG updated
This commit is contained in:
@@ -3,44 +3,17 @@ import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.
|
||||
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||
import { plan } from '../helpers/plan.js';
|
||||
import { effect } from '../helpers/plan.js';
|
||||
import type { StoreOfferEffect } from '../../src/index.js';
|
||||
import type { PlanRun, ScenarioRunFailedEvent } from '../../src/scenario/engine/types.js';
|
||||
|
||||
function makeClient(): RudderClient {
|
||||
return new RudderClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An offer whose `onPurchase` continuation lives server-side (boundary node):
|
||||
* the client doesn't hold the post-purchase DAG — it must call back so the
|
||||
* server can validate the transaction and decide what happens next.
|
||||
*
|
||||
* `onDecline`, by contrast, is a plain local edge handled entirely on-device.
|
||||
*/
|
||||
function offerWithBoundary() {
|
||||
return plan('offer_flow')
|
||||
.runId('offer_flow-run')
|
||||
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy the starter pack!' })
|
||||
.node('consolation', 'notification', { message: 'Maybe next time!' })
|
||||
.edge('offer', 'onDecline', 'consolation')
|
||||
.boundary('offer', 'onPurchase') // server owns what comes after a purchase
|
||||
.build();
|
||||
}
|
||||
|
||||
/** What the server returns from the callback — a fresh plan = a new run. */
|
||||
function rewardContinuation() {
|
||||
return plan('reward_flow')
|
||||
.runId('reward_flow-run')
|
||||
.node('reward', 'notification', { message: 'Reward granted: 500 gems!' })
|
||||
.build();
|
||||
}
|
||||
|
||||
describe('E2E: boundary nodes (server-side continuation)', () => {
|
||||
let gateway: FakeGateway;
|
||||
let client: RudderClient;
|
||||
@@ -61,99 +34,93 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
|
||||
gateway.onEvent('player_login', offerWithBoundary());
|
||||
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
|
||||
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'offer',
|
||||
});
|
||||
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'reward',
|
||||
});
|
||||
gateway.onEvent('player_login', offerEffect);
|
||||
gateway.onCallback('offer', 'onPurchase', reward);
|
||||
|
||||
const messages: string[] = [];
|
||||
const completed: PlanRun[] = [];
|
||||
const completed: string[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
const runtime = await getScenarioRuntime(client);
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
||||
runtime.onRunCompleted = (r) => completed.push(r);
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onNotification((e) => { messages.push(e.message); });
|
||||
client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioId); });
|
||||
|
||||
const token = (await client.auth.loginWithDevice()).accessToken;
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
|
||||
await offer!.buy(offer!.offers[0]);
|
||||
|
||||
// The callback hit the gateway with the boundary's source node/handle + token.
|
||||
const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
|
||||
expect(callback?.body).toEqual({ scenarioId: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run' });
|
||||
expect(callback?.body).toEqual({
|
||||
scenarioId: 'offer_flow',
|
||||
nodeId: 'offer',
|
||||
handle: 'onPurchase',
|
||||
runId: 'offer_flow-run',
|
||||
});
|
||||
expect(callback?.authToken).toBe(token);
|
||||
|
||||
// The server-supplied continuation ran (as a new run), not the local edge.
|
||||
expect(messages).toEqual(['Reward granted: 500 gems!']);
|
||||
expect(messages).not.toContain('Maybe next time!');
|
||||
|
||||
// The original run finished; the continuation ('reward_flow') is now active.
|
||||
expect(completed.map((r) => r.scenarioId)).toContain('offer_flow');
|
||||
expect(runtime.activeRuns.map((r) => r.scenarioId)).toEqual(['reward_flow']);
|
||||
expect(completed).toEqual([]);
|
||||
});
|
||||
|
||||
it('decline stays local → no callback, local edge is followed', async () => {
|
||||
gateway.onEvent('player_login', offerWithBoundary());
|
||||
it('decline posts callback and continues with the server-supplied next effect', async () => {
|
||||
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'offer',
|
||||
});
|
||||
const consolation = effect('notification', { message: 'Maybe next time!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'consolation',
|
||||
});
|
||||
gateway.onEvent('player_login', offerEffect);
|
||||
gateway.onCallback('offer', 'onDecline', consolation);
|
||||
|
||||
const messages: string[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onNotification((e) => { messages.push(e.message); });
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
await offer!.dismiss();
|
||||
|
||||
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(false);
|
||||
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(true);
|
||||
expect(messages).toEqual(['Maybe next time!']);
|
||||
});
|
||||
|
||||
it('a boundary takes precedence over a local edge on the SAME handle', async () => {
|
||||
// `offer` has BOTH a boundary AND a local edge on onPurchase.
|
||||
const conflicting = plan('offer_flow')
|
||||
.runId('offer_flow-conflict')
|
||||
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy!' })
|
||||
.node('local_next', 'notification', { message: 'LOCAL branch' })
|
||||
.edge('offer', 'onPurchase', 'local_next')
|
||||
.boundary('offer', 'onPurchase')
|
||||
.build();
|
||||
gateway.onEvent('player_login', conflicting);
|
||||
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
|
||||
|
||||
const messages: string[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
await offer!.buy(offer!.offers[0]);
|
||||
|
||||
// Only the server continuation ran; the local edge was suppressed.
|
||||
expect(messages).toEqual(['Reward granted: 500 gems!']);
|
||||
expect(messages).not.toContain('LOCAL branch');
|
||||
});
|
||||
|
||||
it('a failing callback does not fail or crash the run', async () => {
|
||||
gateway.onEvent('player_login', offerWithBoundary());
|
||||
const offerEffect = effect('store', { storeSlug: 'starter' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'offer',
|
||||
});
|
||||
gateway.onEvent('player_login', offerEffect);
|
||||
gateway.onCallbackError('offer', 'onPurchase', 500);
|
||||
|
||||
const failures: ScenarioRunFailedEvent[] = [];
|
||||
const completed: PlanRun[] = [];
|
||||
const failures: unknown[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
const runtime = await getScenarioRuntime(client);
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
runtime.onRunFailed = (e) => failures.push(e);
|
||||
runtime.onRunCompleted = (r) => completed.push(r);
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onScenarioFailed((e) => { failures.push(e); });
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
|
||||
// Must not throw despite the 500 from the callback endpoint.
|
||||
await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
|
||||
|
||||
expect(failures).toHaveLength(0); // 500 is transient — no terminal failure
|
||||
// The run stays pending because the boundary call failed transiently.
|
||||
// On reconnection the consumer can retry the purchase completion.
|
||||
expect(failures).toHaveLength(0);
|
||||
expect(runtime.isRunning).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RudderClient } from '../../src/client/RudderClient.js';
|
||||
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||
import { plan } from '../helpers/plan.js';
|
||||
import { effect } from '../helpers/plan.js';
|
||||
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
@@ -13,28 +13,38 @@ function makeClient(): RudderClient {
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null }, // exercised separately in the persistence test
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The offer scenario both branches share:
|
||||
*
|
||||
* login → wait 1m → offer ──onDecline──→ wait 1m → "still available"
|
||||
* └─onPurchase─→ "thanks for your purchase"
|
||||
*/
|
||||
function offerScenario() {
|
||||
return plan('offer_flow')
|
||||
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
|
||||
.node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' })
|
||||
.node('wait_reminder', 'wait', { duration: 1, unit: 'minutes' })
|
||||
.node('reminder', 'notification', { message: 'Your offer is still available!' })
|
||||
.node('thanks', 'notification', { message: 'Thanks for your purchase!' })
|
||||
.edge('wait_intro', 'onComplete', 'offer')
|
||||
.edge('offer', 'onDecline', 'wait_reminder')
|
||||
.edge('wait_reminder', 'onComplete', 'reminder')
|
||||
.edge('offer', 'onPurchase', 'thanks')
|
||||
.build();
|
||||
function offerScenario(now: number) {
|
||||
const waitIntro = effect('wait', {}, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'wait_intro',
|
||||
waitDeadline: new Date(now + MINUTE).toISOString(),
|
||||
});
|
||||
const offer = effect('store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'offer',
|
||||
});
|
||||
const waitReminder = effect('wait', {}, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'wait_reminder',
|
||||
waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
|
||||
});
|
||||
const reminder = effect('notification', { message: 'Your offer is still available!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'reminder',
|
||||
});
|
||||
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'thanks',
|
||||
});
|
||||
return { waitIntro, offer, waitReminder, reminder, thanks };
|
||||
}
|
||||
|
||||
describe('E2E: player offer journey', () => {
|
||||
@@ -66,43 +76,41 @@ describe('E2E: player offer journey', () => {
|
||||
expect(res.accessToken).toBeTruthy();
|
||||
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken);
|
||||
|
||||
// The login request carried the project key + a device id.
|
||||
const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device');
|
||||
expect(login?.body).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' });
|
||||
expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy();
|
||||
|
||||
// The post-login runtime trigger carries the bearer token.
|
||||
const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger');
|
||||
expect(trigger?.authToken).toBe(res.accessToken);
|
||||
});
|
||||
|
||||
it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => {
|
||||
gateway.onEvent('player_login', offerScenario());
|
||||
const now = Date.now();
|
||||
const nodes = offerScenario(now);
|
||||
gateway.onEvent('player_login', nodes.waitIntro);
|
||||
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
|
||||
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
|
||||
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
|
||||
|
||||
const notifications: NotificationEffect[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { notifications.push(effect); });
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onNotification((e) => { notifications.push(e); });
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
|
||||
// Immediately after login the player is just waiting — no offer yet.
|
||||
expect(offer).toBeUndefined();
|
||||
|
||||
// The offer must NOT appear before the full minute has elapsed...
|
||||
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
|
||||
expect(offer).toBeUndefined();
|
||||
|
||||
// ...and surfaces exactly when the minute is up.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
expect(offer!.message).toBe('Limited starter pack!');
|
||||
|
||||
// Player declines → the DAG moves to the reminder wait, no notification yet.
|
||||
await offer!.dismiss();
|
||||
expect(notifications).toHaveLength(0);
|
||||
|
||||
// The reminder also honours the full minute, not a moment sooner.
|
||||
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
|
||||
expect(notifications).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
@@ -111,38 +119,46 @@ describe('E2E: player offer journey', () => {
|
||||
});
|
||||
|
||||
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
|
||||
gateway.onEvent('player_login', offerScenario());
|
||||
const now = Date.now();
|
||||
const nodes = offerScenario(now);
|
||||
gateway.onEvent('player_login', nodes.waitIntro);
|
||||
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
|
||||
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
|
||||
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
const notifications: NotificationEffect[] = [];
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { notifications.push(effect); });
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onNotification((e) => { notifications.push(e); });
|
||||
|
||||
const accessToken = (await client.auth.loginWithDevice()).accessToken;
|
||||
await vi.advanceTimersByTimeAsync(MINUTE);
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
|
||||
// The game buys a selected offer; the session advances only after success.
|
||||
const selectedOffer = offer!.offers[0];
|
||||
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
|
||||
expect(purchase.success).toBe(true);
|
||||
|
||||
// The purchase hit the gateway with idempotency key + bearer token.
|
||||
expect(gateway.purchases).toEqual([
|
||||
{ storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken },
|
||||
]);
|
||||
|
||||
// The scenario took the onPurchase branch → "thanks", and the run finished.
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].message).toBe('Thanks for your purchase!');
|
||||
});
|
||||
|
||||
it('declining does NOT take the purchase branch (handles are isolated)', async () => {
|
||||
gateway.onEvent('player_login', offerScenario());
|
||||
const now = Date.now();
|
||||
const nodes = offerScenario(now);
|
||||
gateway.onEvent('player_login', nodes.waitIntro);
|
||||
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
|
||||
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
|
||||
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
|
||||
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
|
||||
|
||||
const messages: string[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
||||
client.effects.onStoreOffer((e) => { offer = e; });
|
||||
client.effects.onNotification((e) => { messages.push(e.message); });
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
await vi.advanceTimersByTimeAsync(MINUTE);
|
||||
|
||||
@@ -2,39 +2,44 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
|
||||
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||
import { createMemoryPlanStore, stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||
import { plan } from '../helpers/plan.js';
|
||||
import type { StoreSession, WaitSession } from '../../src/scenario/engine/sessions.js';
|
||||
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||
import { effect } from '../helpers/plan.js';
|
||||
import type { StoreOfferEffect, WaitEffect } from '../../src/index.js';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
describe('E2E: scenario state survives a reload', () => {
|
||||
describe('E2E: pending effects resume after a new login', () => {
|
||||
let gateway: FakeGateway;
|
||||
// Shared backing store mimics IndexedDB persisting across page loads.
|
||||
const backing = { value: null as string | null };
|
||||
|
||||
function clientWithSharedStore(): RudderClient {
|
||||
function makeClient(): RudderClient {
|
||||
return new RudderClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: createMemoryPlanStore(backing), loginEvent: null },
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubDeterministicUuid();
|
||||
vi.useFakeTimers();
|
||||
backing.value = null;
|
||||
gateway = createFakeGateway();
|
||||
gateway.onEvent(
|
||||
'session_start',
|
||||
plan('offer_flow')
|
||||
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
|
||||
.node('offer', 'store', { message: 'Limited starter pack!' })
|
||||
.edge('wait_intro', 'onComplete', 'offer')
|
||||
.build(),
|
||||
);
|
||||
gateway = createFakeGateway({
|
||||
stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', name: 'Pack' }] }],
|
||||
});
|
||||
const now = Date.now();
|
||||
const wait = effect('wait', {}, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'wait_intro',
|
||||
waitDeadline: new Date(now + MINUTE).toISOString(),
|
||||
});
|
||||
const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, {
|
||||
scenarioId: 'offer_flow',
|
||||
runId: 'offer_flow-run',
|
||||
nodeId: 'offer',
|
||||
});
|
||||
gateway.onEvent('session_start', wait);
|
||||
gateway.onCallback('wait_intro', 'onComplete', offer);
|
||||
gateway.install();
|
||||
});
|
||||
|
||||
@@ -43,33 +48,26 @@ describe('E2E: scenario state survives a reload', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('a wait started before reload resumes and fires the offer after reload', async () => {
|
||||
// --- Session 1: trigger the scenario, then "close the tab" mid-wait ---
|
||||
const client1 = clientWithSharedStore();
|
||||
it('a wait started before reload resumes from GET pending after login', async () => {
|
||||
const client1 = makeClient();
|
||||
await client1.auth.loginWithDevice();
|
||||
const runtime1 = await getScenarioRuntime(client1);
|
||||
await runtime1.send('session_start');
|
||||
expect(runtime1.isRunning).toBe(true);
|
||||
expect(backing.value).toBeTruthy(); // run was persisted
|
||||
|
||||
// --- Session 2: fresh client, same persisted state (page reload) ---
|
||||
const client2 = clientWithSharedStore();
|
||||
const runtime2 = await getScenarioRuntime(client2);
|
||||
const resumedWaits: WaitSession[] = [];
|
||||
let offer: StoreSession | undefined;
|
||||
runtime2.onWait = (s) => resumedWaits.push(s);
|
||||
runtime2.onStore = (s) => { offer = s; };
|
||||
const client2 = makeClient();
|
||||
const resumedWaits: WaitEffect[] = [];
|
||||
let offer: StoreOfferEffect | undefined;
|
||||
client2.effects.onWait((s) => { resumedWaits.push(s); });
|
||||
client2.effects.onStoreOffer((s) => { offer = s; });
|
||||
|
||||
await client2.auth.loginWithDevice();
|
||||
|
||||
// The wait node was rehydrated and re-dispatched.
|
||||
expect(runtime2.isRunning).toBe(true);
|
||||
expect(resumedWaits).toHaveLength(1);
|
||||
expect(offer).toBeUndefined();
|
||||
|
||||
// The remaining wait time still elapses → the offer surfaces on the new client.
|
||||
await vi.advanceTimersByTimeAsync(MINUTE);
|
||||
expect(offer).toBeDefined();
|
||||
expect(offer!.get('message', '')).toBe('Limited starter pack!');
|
||||
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||
expect(offer!.message).toBe('Limited starter pack!');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { RudderClient } from '../../src/client/RudderClient.js';
|
||||
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||
import { plan } from '../helpers/plan.js';
|
||||
import type { RemoteConfig } from '../../src/generated/remote-config.js';
|
||||
|
||||
interface GameRemoteConfig extends Record<string, unknown> {
|
||||
@@ -25,7 +24,7 @@ function makeClient(): RudderClient<GameRemoteConfig> {
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null, loginEvent: null },
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,26 +75,4 @@ describe('E2E: remote config', () => {
|
||||
expect(client.remoteConfig.status).toBe('idle');
|
||||
expect(client.remoteConfig.get('max_energy', 99)).toBe(99);
|
||||
});
|
||||
|
||||
it('a remote_config_override scenario node patches the live cache', async () => {
|
||||
client = new RudderClient<GameRemoteConfig>({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null },
|
||||
});
|
||||
gateway.onEvent(
|
||||
'player_login',
|
||||
plan('boost')
|
||||
.node('override', 'remote_config_override', {
|
||||
patches: [{ path: 'drop_rate', valueType: 'float', value: '0.9' }],
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
|
||||
// The override is reflected immediately, without a reload.
|
||||
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ function makeClient(): RudderClient {
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { planStateStore: null, loginEvent: null },
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user