Files
openhuman/app/test/e2e/specs/rewards-progression-persistence.spec.ts
T

271 lines
12 KiB
TypeScript

import { waitForApp } from '../helpers/app-helpers';
import { textExists, waitForText } from '../helpers/element-helpers';
import { supportsExecuteScript } from '../helpers/platform';
import { resetApp } from '../helpers/reset-app';
import {
resetMockBehavior,
setMockBehavior,
startMockServer,
stopMockServer,
} from '../mock-server';
/**
* Rewards & Progression — progress-tracking persistence (matrix rows
* 12.2.1 / 12.2.2 / 12.2.3).
*
* Goal: prove that the Rewards page surfaces message-driven progress, usage
* metrics, and that those values persist across a simulated app restart
* (re-mounting the page after a fresh fetch).
*
* Per-case strategy:
* - 12.2.1 message count tracking: there is no literal `messageCount` field
* in the snapshot — message-driven progress is proxied by
* `metrics.featuresUsedCount` (a counter the backend bumps when a
* message exercises a tracked feature). We assert via
* `__OPENHUMAN_STORE__`-style window probe (snapshot lives in component
* state, not Redux, so we read the rendered text instead). High-usage
* scenario sets featuresUsedCount=6; we confirm cumulativeTokens render
* reflects the high number.
* - 12.2.2 usage metrics: assert the `Current streak` + `Cumulative tokens`
* rows in the metrics footer reflect the high-usage scenario values.
* - 12.2.3 state persistence: switch to `post_restart` (same metric values,
* later `lastSyncedAt`) to simulate a backend re-sync after the app
* restarted; navigate away, prime the new scenario, navigate back, and
* confirm the metrics survive (cumulative tokens + streak are stable;
* lastSyncedAt advanced).
*
* Mac2 skipped — same rationale as `rewards-unlock-flow.spec.ts`: rewards
* surface is rendered in the WKWebView and our Appium selectors do not yet
* cover the bottom-tab `Rewards` label cleanly.
*/
function stepLog(message: string, context?: unknown): void {
const stamp = new Date().toISOString();
if (context === undefined) {
console.log(`[RewardsProgressionE2E][${stamp}] ${message}`);
return;
}
console.log(`[RewardsProgressionE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2));
}
async function navigateToRewards(): Promise<void> {
// Navigate to /home first so the Rewards component always re-mounts.
// Without this, if already at /rewards, setting the same hash is a no-op
// and the component never re-fetches the primed mock scenario.
await browser.execute(() => {
window.location.hash = '/home';
});
await browser.pause(1_000);
await browser.execute(() => {
window.location.hash = '/rewards';
});
await browser.pause(2_000);
}
async function navigateAway(): Promise<void> {
// Send the hash router back to /home so the Rewards page unmounts and the
// next navigation re-runs the on-mount fetch (the app's restart-equivalent
// for this surface — `Rewards.tsx` only loads on mount, so unmount-remount
// is the cheapest way to re-hit `/rewards/me` without a full browser
// restart that tauri-driver does not support cheaply).
await browser.execute(() => {
window.location.hash = '/home';
});
await browser.pause(2_000);
}
async function waitForRewardsSnapshot(timeout = 15_000): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (await textExists('Your Progress')) {
const stillLoading = await textExists('Loading rewards…');
if (!stillLoading) return;
}
await browser.pause(400);
}
throw new Error('[RewardsProgressionE2E] Rewards page did not finish loading snapshot in time');
}
async function getRewardsMetricValue(label: string): Promise<string | null> {
return browser.execute(metricLabel => {
const labels = Array.from(document.querySelectorAll('span'));
const labelNode = labels.find(node => node.textContent?.trim() === metricLabel);
const row = labelNode?.parentElement;
if (!row) return null;
const valueNode = Array.from(row.querySelectorAll('span')).find(node => node !== labelNode);
return valueNode?.textContent?.trim() ?? null;
}, label);
}
describe('Rewards progression & persistence', () => {
before(async function beforeSuite() {
if (!supportsExecuteScript()) {
stepLog('Skipping suite on Mac2 — Rewards bottom-tab label not mapped for Appium');
this.skip();
}
stepLog('starting mock server');
await startMockServer();
stepLog('waiting for app');
await waitForApp();
stepLog('resetting app with e2e-rewards-progression identity');
await resetApp('e2e-rewards-progression');
});
after(async () => {
stepLog('resetting mock behavior');
resetMockBehavior();
stepLog('stopping mock server');
await stopMockServer();
});
it('12.2.1 — message-driven progress is reflected in the unlocked-count summary', async () => {
stepLog(
'priming high_usage scenario (featuresUsedCount=6, cumulativeTokens=12.5M, streak=14d)'
);
resetMockBehavior();
setMockBehavior('rewardsScenario', 'high_usage');
await navigateToRewards();
await waitForText('Your Progress', 15_000);
await waitForRewardsSnapshot();
// Server returns unlockedCount=3 / totalCount=3 for the high_usage
// scenario — proves the message-driven progress threshold lit all 3
// achievements. The summary line is the single grep-friendly anchor
// for this assertion.
expect(await textExists('3 of 3 achievements unlocked')).toBe(true);
// Each of the three achievement titles is present.
expect(await textExists('7-Day Streak')).toBe(true);
expect(await textExists('Discord Member')).toBe(true);
expect(await textExists('Pro Supporter')).toBe(true);
});
it('12.2.2 — usage metrics (current streak + cumulative tokens) render the snapshot values', async () => {
stepLog('priming high_usage scenario for metrics footer');
resetMockBehavior();
setMockBehavior('rewardsScenario', 'high_usage');
// Navigate away first so the page remount fires a fresh fetch — leaves
// the case runnable in isolation (mocha --grep) without depending on
// ordering with case 12.2.1 above.
await navigateAway();
await navigateToRewards();
await waitForText('Your Progress', 15_000);
await waitForRewardsSnapshot();
// Current streak row in the metrics footer.
expect(await textExists('Current streak')).toBe(true);
expect(await getRewardsMetricValue('Current streak')).toBe('14');
// Cumulative tokens row — value formatted via en-US Intl.NumberFormat
// (see RewardsCommunityTab.formatNumber). 12_500_000 → "12,500,000".
expect(await textExists('Cumulative tokens')).toBe(true);
expect(await getRewardsMetricValue('Cumulative tokens')).toBe('12,500,000');
});
it('12.2.3 — state persists across a simulated restart (re-fetch on remount)', async () => {
// Phase 1: load the high-usage snapshot with a fixed lastSyncedAt so we
// can prove the second fetch advanced the timestamp without changing
// the durable counters.
const beforeRestartSyncedAt = '2026-04-28T09:00:00.000Z';
const afterRestartSyncedAt = '2026-04-28T10:30:00.000Z';
stepLog('Phase 1: priming high_usage with pre-restart lastSyncedAt');
resetMockBehavior();
setMockBehavior('rewardsScenario', 'high_usage');
setMockBehavior('rewardsLastSyncedAt', beforeRestartSyncedAt);
await navigateAway();
await navigateToRewards();
await waitForText('Your Progress', 15_000);
await waitForRewardsSnapshot();
// Capture the durable counters from the rendered DOM before the restart.
expect(await getRewardsMetricValue('Current streak')).toBe('14');
expect(await getRewardsMetricValue('Cumulative tokens')).toBe('12,500,000');
// Phase 2: simulate a restart by unmounting Rewards (navigate away),
// priming the post_restart scenario (same counters, later
// lastSyncedAt), then re-mounting Rewards. This mimics what happens on
// app restart — the in-memory snapshot is gone, the page re-fetches
// `/rewards/me`, and the durable backend state must repopulate the UI.
stepLog('Phase 2: navigating away + flipping to post_restart scenario');
await navigateAway();
resetMockBehavior();
setMockBehavior('rewardsScenario', 'post_restart');
setMockBehavior('rewardsLastSyncedAt', afterRestartSyncedAt);
stepLog('Phase 2: re-navigating to /rewards (simulated restart)');
await navigateToRewards();
await waitForText('Your Progress', 15_000);
await waitForRewardsSnapshot();
// Durable counters must survive the restart unchanged.
expect(await getRewardsMetricValue('Current streak')).toBe('14');
expect(await getRewardsMetricValue('Cumulative tokens')).toBe('12,500,000');
expect(await textExists('3 of 3 achievements unlocked')).toBe(true);
// Verify the second `/rewards/me` request landed on the mock — the
// request log is the authoritative signal that the page actually
// re-fetched (and the server returned the post-restart timestamp).
// The mock-api admin requests endpoint enumerates every request the
// server has received since the server started, with the latest at
// the tail. Filter for `/rewards/me` GETs and assert at least 2 (one
// per phase).
const rewardsRequestCount = await browser.execute(async () => {
const apiBase =
(window as unknown as { __OPENHUMAN_API_BASE__?: string }).__OPENHUMAN_API_BASE__ ??
'http://127.0.0.1:18473';
const res = await fetch(`${apiBase}/__admin/requests`);
const json = (await res.json()) as { data?: Array<{ method: string; url: string }> };
const log = json.data ?? [];
return log.filter(r => r.method === 'GET' && /^\/rewards\/me/.test(r.url)).length;
});
stepLog('rewards/me request count after restart simulation', { rewardsRequestCount });
expect(rewardsRequestCount).toBeGreaterThanOrEqual(2);
});
it('12.2.4 — stalled rewards endpoint past timeout shows recoverable error with retry affordance', async () => {
stepLog('priming rewardsDelayMs=20000 — response arrives after the 15s app-side timeout');
resetMockBehavior();
setMockBehavior('rewardsDelayMs', '20000');
await navigateAway();
await navigateToRewards();
// The Rewards page renders an error state containing "Sync unavailable"
// and a retry button after the 15 s REWARDS_SNAPSHOT_TIMEOUT_MS fires.
// Give the page up to 30 s to time out and render the error UI.
const sawError = await waitForText('Sync unavailable', 30_000).then(
() => true,
() => false
);
if (!sawError) {
stepLog('WARN: "Sync unavailable" not seen — checking for any error marker');
}
expect(sawError || (await textExists('Retrying'))).toBe(true);
// The retry button must be present so the user can recover without restart.
const hasRetry = await textExists('Retrying');
expect(hasRetry).toBe(true);
});
it('12.2.5 — retry after timeout recovers and renders normalized rewards data', async () => {
stepLog('clearing delay so next request responds immediately');
resetMockBehavior();
setMockBehavior('rewardsScenario', 'high_usage');
// Navigate away so the retry is a fresh mount (mirroring user navigating
// back after the stall rather than clicking the retry button directly,
// since clicking into the delayed response is racy).
await navigateAway();
await navigateToRewards();
await waitForText('Your Progress', 15_000);
await waitForRewardsSnapshot();
expect(await textExists('3 of 3 achievements unlocked')).toBe(true);
expect(await getRewardsMetricValue('Current streak')).toBe('14');
});
});