Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions devvit.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
]
},
"permissions": {
"journeys": true,
"reddit": {
"enable": true,
"asUser": ["SUBMIT_POST", "SUBMIT_COMMENT", "SUBSCRIBE_TO_SUBREDDIT"]
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"node": ">=24.0.0"
},
"dependencies": {
"@devvit/analytics": "0.13.11",
"@devvit/start": "0.13.11",
"@devvit/test": "0.13.11",
"@devvit/web": "0.13.11",
Expand Down
15 changes: 10 additions & 5 deletions src/client/game.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import './index.css';
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { navigateTo } from '@devvit/web/client';
import { getJourneyId } from './journeys';
import { trpc } from './trpc';
import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '../server/trpc';
Expand All @@ -27,14 +28,18 @@ export const App = () => {

const increment = async () => {
setLoading(true);
const result = await trpc.counter.increment.mutate();
const result = await trpc.counter.increment.mutate({
journeyId: getJourneyId(),
});
setInit((prev) => (prev ? { ...prev, count: result.count } : null));
setLoading(false);
};

const decrement = async () => {
setLoading(true);
const result = await trpc.counter.decrement.mutate();
const result = await trpc.counter.decrement.mutate({
journeyId: getJourneyId(),
});
setInit((prev) => (prev ? { ...prev, count: result.count } : null));
setLoading(false);
};
Expand Down Expand Up @@ -79,21 +84,21 @@ export const App = () => {
</div>
<footer className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-3 text-[0.8em] text-gray-600 dark:text-gray-400">
<button
className="cursor-pointer hover:text-gray-900 dark:hover:text-white transition-colors"
className="cursor-pointer transition-colors hover:text-gray-900 dark:hover:text-white"
onClick={() => navigateTo('https://developers.reddit.com/docs')}
>
Docs
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
className="cursor-pointer hover:text-gray-900 dark:hover:text-white transition-colors"
className="cursor-pointer transition-colors hover:text-gray-900 dark:hover:text-white"
onClick={() => navigateTo('https://www.reddit.com/r/Devvit')}
>
r/Devvit
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
className="cursor-pointer hover:text-gray-900 dark:hover:text-white transition-colors"
className="cursor-pointer transition-colors hover:text-gray-900 dark:hover:text-white"
onClick={() => navigateTo('https://discord.com/invite/R7yu2wh9Qz')}
>
Discord
Expand Down
19 changes: 19 additions & 0 deletions src/client/journeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const JOURNEY_ID_STORAGE_KEY = 'devvit.telemetry.journeyId';

export const getJourneyId = (): string | undefined => {
try {
return sessionStorage.getItem(JOURNEY_ID_STORAGE_KEY) ?? undefined;
} catch {
return undefined;
}
};

export const setJourneyId = (journeyId: string): void => {
if (!journeyId) return;

try {
sessionStorage.setItem(JOURNEY_ID_STORAGE_KEY, journeyId);
} catch {
// Journey telemetry remains optional when browser storage is unavailable.
}
};
45 changes: 35 additions & 10 deletions src/client/splash.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

let requestExpandedModeMock: ReturnType<typeof vi.fn>;
let navigateToMock: ReturnType<typeof vi.fn>;
let appReadyMock: ReturnType<typeof vi.fn>;
let startJourneyMock: ReturnType<typeof vi.fn>;

vi.mock('./trpc', () => {
appReadyMock = vi.fn().mockResolvedValue(undefined);
startJourneyMock = vi.fn().mockResolvedValue({ journeyId: 'journey-id' });

return {
trpc: {
journeys: {
appReady: { mutate: appReadyMock },
start: { mutate: startJourneyMock },
},
},
};
});

vi.mock('@devvit/web/client', () => {
requestExpandedModeMock = vi.fn();
Expand All @@ -19,20 +35,29 @@ vi.mock('@devvit/web/client', () => {
};
});

afterEach(() => {
requestExpandedModeMock?.mockReset();
navigateToMock?.mockReset();
});

describe('Splash', () => {
it('clicking the "Docs" footer button calls navigateTo(...)', async () => {
it('reports app ready and starts a journey when the user taps to start', async () => {
document.body.innerHTML = '<div id="root"></div>';
sessionStorage.clear();

// `src/splash.tsx` renders immediately on import (createRoot(...).render(...))
await import('./splash');
await vi.waitFor(() => expect(appReadyMock).toHaveBeenCalledTimes(1));

const startButton = Array.from(document.querySelectorAll('button')).find(
(button) => /tap to start/i.test(button.textContent ?? '')
);
expect(startButton).toBeTruthy();

startButton!.click();
startButton!.click();

// Let React commit the initial render.
await new Promise((r) => setTimeout(r, 0));
expect(startJourneyMock).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(sessionStorage.getItem('devvit.telemetry.journeyId')).toBe(
'journey-id'
);
});
expect(requestExpandedModeMock).toHaveBeenCalledTimes(1);

const docsButton = Array.from(document.querySelectorAll('button')).find(
(b) => /docs/i.test(b.textContent ?? '')
Expand Down
35 changes: 30 additions & 5 deletions src/client/splash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,24 @@ import './index.css';

import { navigateTo } from '@devvit/web/client';
import { context, requestExpandedMode } from '@devvit/web/client';
import { StrictMode } from 'react';
import { StrictMode, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { setJourneyId } from './journeys';
import { trpc } from './trpc';

let hasReportedAppReady = false;
let hasStartedJourney = false;

export const Splash = () => {
useEffect(() => {
if (hasReportedAppReady) return;

hasReportedAppReady = true;
void trpc.journeys.appReady.mutate().catch((error: unknown) => {
console.warn('Failed to record app ready.', error);
});
}, []);

return (
<div className="relative flex min-h-screen flex-col items-center justify-center gap-4 bg-white dark:bg-gray-900">
<img
Expand All @@ -28,28 +42,39 @@ export const Splash = () => {
<div className="mt-5 flex items-center justify-center">
<button
className="flex h-10 w-auto cursor-pointer items-center justify-center rounded-full bg-[#d93900] px-4 text-white transition-colors hover:bg-[#c23300] dark:bg-orange-600 dark:hover:bg-orange-700"
onClick={(e) => requestExpandedMode(e.nativeEvent, 'game')}
onClick={async (e) => {
if (hasStartedJourney) return;

hasStartedJourney = true;
try {
const { journeyId } = await trpc.journeys.start.mutate();
setJourneyId(journeyId);
} catch (error) {
console.warn('Failed to start journey.', error);
}
requestExpandedMode(e.nativeEvent, 'game');
}}
>
Tap to Start
</button>
</div>
<footer className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-3 text-[0.8em] text-gray-600 dark:text-gray-400">
<button
className="cursor-pointer hover:text-gray-900 dark:hover:text-white transition-colors"
className="cursor-pointer transition-colors hover:text-gray-900 dark:hover:text-white"
onClick={() => navigateTo('https://developers.reddit.com/docs')}
>
Docs
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
className="cursor-pointer hover:text-gray-900dark:hover:text-white transition-colors"
className="hover:text-gray-900dark:hover:text-white cursor-pointer transition-colors"
onClick={() => navigateTo('https://www.reddit.com/r/Devvit')}
>
r/Devvit
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
className="cursor-pointer hover:text-gray-900dark:hover:text-white transition-colors"
className="hover:text-gray-900dark:hover:text-white cursor-pointer transition-colors"
onClick={() => navigateTo('https://discord.com/invite/R7yu2wh9Qz')}
>
Discord
Expand Down
102 changes: 102 additions & 0 deletions src/server/trpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const {
appReadyMock,
countDecrementMock,
countIncrementMock,
journeyProgressMock,
startJourneyMock,
} = vi.hoisted(() => ({
appReadyMock: vi.fn(),
countDecrementMock: vi.fn(),
countIncrementMock: vi.fn(),
journeyProgressMock: vi.fn(),
startJourneyMock: vi.fn(),
}));

vi.mock('@devvit/analytics/server/reddit', () => ({
telemetry: {
appReady: appReadyMock,
journeyProgress: journeyProgressMock,
startJourney: startJourneyMock,
},
}));

vi.mock('@devvit/web/server', () => ({
context: { postId: 'post-id' },
reddit: { getCurrentUsername: vi.fn() },
}));

vi.mock('./core/count', () => ({
countDecrement: countDecrementMock,
countGet: vi.fn(),
countIncrement: countIncrementMock,
}));

import { appRouter } from './trpc';

const caller = appRouter.createCaller({});

beforeEach(() => {
appReadyMock.mockReset();
countDecrementMock.mockReset();
countIncrementMock.mockReset();
journeyProgressMock.mockReset();
startJourneyMock.mockReset();
});

describe('journeys', () => {
it('reports app ready and starts a journey', async () => {
const appReadyResponse = { receipt: { status: 'ready' } };
const startResponse = {
journeyId: 'journey-id',
receipt: { status: 'started' },
};
appReadyMock.mockResolvedValue(appReadyResponse);
startJourneyMock.mockResolvedValue(startResponse);

await expect(caller.journeys.appReady()).resolves.toEqual(appReadyResponse);
await expect(caller.journeys.start()).resolves.toEqual(startResponse);
});

it.each([
{
action: 'increment' as const,
count: 1,
counter: countIncrementMock,
},
{
action: 'decrement' as const,
count: -1,
counter: countDecrementMock,
},
])('records progress after $action', async ({ action, count, counter }) => {
counter.mockResolvedValue(count);
journeyProgressMock.mockResolvedValue({ receipt: { status: 'recorded' } });

const result = await caller.counter[action]({ journeyId: 'journey-id' });

expect(result.count).toBe(count);
expect(journeyProgressMock).toHaveBeenCalledWith({
journeyId: 'journey-id',
progress: 0.5,
action,
actionDetails: 'counter',
});
expect(counter.mock.invocationCallOrder[0]).toBeLessThan(
journeyProgressMock.mock.invocationCallOrder[0]!
);
});

it('does not fail a counter update when progress telemetry fails', async () => {
const warnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
countIncrementMock.mockResolvedValue(1);
journeyProgressMock.mockRejectedValue(new Error('telemetry unavailable'));

await expect(
caller.counter.increment({ journeyId: 'journey-id' })
).resolves.toMatchObject({ count: 1 });
expect(warnMock).toHaveBeenCalledOnce();
warnMock.mockRestore();
});
});
Loading