@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user