mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
271 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|