1491b5e357
- 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
127 lines
4.5 KiB
TypeScript
127 lines
4.5 KiB
TypeScript
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 { effect } from '../helpers/plan.js';
|
|
import type { StoreOfferEffect } from '../../src/index.js';
|
|
|
|
function makeClient(): RudderClient {
|
|
return new RudderClient({
|
|
baseUrl: 'https://api.test.rudder.build',
|
|
projectKey: 'test-project',
|
|
tokenStore: createFakeTokenStore(),
|
|
});
|
|
}
|
|
|
|
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 () => {
|
|
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: string[] = [];
|
|
let offer: StoreOfferEffect | undefined;
|
|
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]);
|
|
|
|
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);
|
|
|
|
expect(messages).toEqual(['Reward granted: 500 gems!']);
|
|
expect(messages).not.toContain('Maybe next time!');
|
|
expect(completed).toEqual([]);
|
|
});
|
|
|
|
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((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(true);
|
|
expect(messages).toEqual(['Maybe next time!']);
|
|
});
|
|
|
|
it('a failing callback does not fail or crash the run', async () => {
|
|
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: unknown[] = [];
|
|
let offer: StoreOfferEffect | undefined;
|
|
const runtime = await getScenarioRuntime(client);
|
|
client.effects.onStoreOffer((e) => { offer = e; });
|
|
client.effects.onScenarioFailed((e) => { failures.push(e); });
|
|
|
|
await client.auth.loginWithDevice();
|
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
|
|
|
await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
|
|
|
|
expect(failures).toHaveLength(0);
|
|
expect(runtime.isRunning).toBe(true);
|
|
});
|
|
});
|