feat(rewards): Discord connect + disconnect/re-link on the Rewards page (#3766)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
YellowSnnowmann
2026-06-19 11:38:08 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent e2355ba142
commit 1f00ab79b1
25 changed files with 554 additions and 34 deletions
@@ -2,6 +2,7 @@ import createDebug from 'debug';
import { useCallback, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { rewardsApi } from '../../services/api/rewardsApi';
import { callCoreRpc } from '../../services/coreRpcClient';
import type { RewardsAchievement, RewardsSnapshot } from '../../types/rewards';
import { DISCORD_INVITE_URL } from '../../utils/links';
@@ -91,6 +92,9 @@ export default function RewardsCommunityTab({
}: RewardsCommunityTabProps) {
const { t } = useT();
const [connectState, setConnectState] = useState<'idle' | 'connecting' | 'error'>('idle');
const [disconnectState, setDisconnectState] = useState<'idle' | 'disconnecting' | 'error'>(
'idle'
);
const rewardRoles: RewardsAchievement[] = snapshot?.achievements ?? [];
const unlocked =
snapshot?.summary.unlockedCount ?? rewardRoles.filter(role => role.unlocked).length;
@@ -103,6 +107,13 @@ export default function RewardsCommunityTab({
const ringOffset = ringCircumference - (progressPercent / 100) * ringCircumference;
const discordLinked = snapshot?.discord.linked ?? false;
const discordUsername = snapshot?.discord.username ?? null;
const membershipStatus = snapshot?.discord.membershipStatus ?? null;
const assignedRoleCount = snapshot?.summary.assignedDiscordRoleCount ?? 0;
// A connected member who unlocked a role-bearing achievement but has not joined the
// server yet cannot receive the role — surface an actionable prompt to join.
const hasUnlockedConfiguredRole = rewardRoles.some(role => role.unlocked && Boolean(role.roleId));
const showClaimBanner =
discordLinked && membershipStatus === 'not_in_guild' && hasUnlockedConfiguredRole;
const handleConnectDiscord = useCallback(async () => {
log('connect discord requested');
@@ -129,6 +140,23 @@ export default function RewardsCommunityTab({
setConnectState('error');
}
}, []);
const handleDisconnectDiscord = useCallback(async () => {
log('disconnect discord requested');
setDisconnectState('disconnecting');
try {
// Clears user.discordId/discordUsername on the backend (idempotent), which flips the
// rewards snapshot back to unlinked.
await rewardsApi.disconnectDiscord();
log('disconnect discord ok; refreshing snapshot');
setDisconnectState('idle');
// Refetch the snapshot so the connected state flips back to the Connect button (re-link path).
onRetry?.();
} catch (err) {
log('disconnect discord failed error=%s', err instanceof Error ? err.message : String(err));
setDisconnectState('error');
}
}, [onRetry]);
return (
<>
<section className="relative overflow-hidden rounded-[1.25rem] bg-gradient-to-br from-[#004ad0] to-[#2b64f1] p-6 text-white shadow-[0_20px_40px_rgba(25,28,30,0.08)]">
@@ -143,16 +171,36 @@ export default function RewardsCommunityTab({
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{discordLinked ? (
<div
data-testid="rewards-discord-connected"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white/15 px-4 py-3 text-sm font-semibold text-white">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{discordUsername
? t('rewards.community.discordConnectedAs').replace('{username}', discordUsername)
: t('rewards.community.discordConnected')}
</div>
<>
<div
data-testid="rewards-discord-connected"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white/15 px-4 py-3 text-sm font-semibold text-white">
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true">
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{discordUsername
? t('rewards.community.discordConnectedAs').replace(
'{username}',
discordUsername
)
: t('rewards.community.discordConnected')}
</div>
<button
onClick={() => {
void handleDisconnectDiscord();
}}
disabled={disconnectState === 'disconnecting'}
data-testid="rewards-disconnect-discord"
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/20 bg-white/10 px-4 py-3 text-sm font-semibold text-white backdrop-blur-sm transition-colors hover:bg-white/15 disabled:cursor-not-allowed disabled:opacity-70">
{disconnectState === 'disconnecting'
? t('rewards.community.disconnectingDiscord')
: t('rewards.community.disconnectDiscord')}
</button>
</>
) : (
<button
onClick={() => {
@@ -198,6 +246,14 @@ export default function RewardsCommunityTab({
{t('rewards.community.connectDiscordError')}
</p>
) : null}
{discordLinked && disconnectState === 'error' ? (
<p
role="alert"
data-testid="rewards-disconnect-discord-error"
className="text-xs font-medium text-white/90">
{t('rewards.community.disconnectDiscordError')}
</p>
) : null}
</div>
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-white/10 blur-2xl" />
<div className="absolute -bottom-10 -left-8 h-24 w-24 rounded-full bg-white/15 blur-xl" />
@@ -291,6 +347,30 @@ export default function RewardsCommunityTab({
{t('rewards.community.rolesAndRewards')}
</h2>
</div>
{showClaimBanner ? (
<div
role="status"
data-testid="rewards-claim-roles-banner"
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-blue-100 dark:border-blue-500/30 bg-blue-50 dark:bg-blue-500/10 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-bold text-stone-900 dark:text-neutral-100">
{t('rewards.community.roleClaimTitle')}
</p>
<p className="mt-0.5 text-xs leading-relaxed text-stone-600 dark:text-neutral-300">
{t('rewards.community.roleClaimDesc')}
</p>
</div>
<button
type="button"
data-testid="rewards-claim-roles-join"
onClick={() => {
void openUrl(inviteUrl);
}}
className="inline-flex flex-shrink-0 items-center justify-center gap-2 rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-primary-700">
{t('rewards.community.joinDiscord')}
</button>
</div>
) : null}
{isLoading ? (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 shadow-soft">
<div className="text-sm text-stone-600 dark:text-neutral-300">
@@ -300,6 +380,30 @@ export default function RewardsCommunityTab({
) : rewardRoles.length > 0 ? (
rewardRoles.map((role, index) => {
const tone = roleAccentTone(index);
// Surface Discord role-assignment status only for a linked user's unlocked
// achievements — locked badges have no role to claim yet.
const roleStatus =
discordLinked && role.unlocked
? role.discordRoleStatus === 'assigned'
? {
label: t('rewards.community.roleAssigned'),
classes:
'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300',
}
: role.discordRoleStatus === 'not_assigned'
? {
label: t('rewards.community.rolePending'),
classes:
'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300',
}
: role.discordRoleStatus === 'not_in_guild'
? {
label: t('rewards.community.roleJoinToClaim'),
classes:
'bg-blue-50 text-primary-700 dark:bg-blue-500/10 dark:text-primary-300',
}
: null
: null;
return (
<div
@@ -345,6 +449,15 @@ export default function RewardsCommunityTab({
</svg>
</div>
</div>
{roleStatus ? (
<div className="mt-3">
<span
data-testid={`rewards-role-status-${role.id}`}
className={`inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-semibold ${roleStatus.classes}`}>
{roleStatus.label}
</span>
</div>
) : null}
</div>
);
})
@@ -385,6 +498,18 @@ export default function RewardsCommunityTab({
</span>
</div>
) : null}
{discordLinked && membershipStatus === 'member' ? (
<div className="mt-3 flex items-center justify-between gap-3">
<span>{t('rewards.community.rolesAndRewards')}</span>
<span
data-testid="rewards-roles-assigned"
className="font-semibold text-stone-900 dark:text-neutral-100">
{t('rewards.community.roleAssignmentCount')
.replace('{assigned}', String(assignedRoleCount))
.replace('{unlocked}', String(unlocked))}
</span>
</div>
) : null}
<div className="mt-3 flex items-center justify-between gap-3">
<span>{t('rewards.community.currentStreak')}</span>
<span className="font-semibold text-stone-900 dark:text-neutral-100">
@@ -9,14 +9,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { RewardsSnapshot } from '../../../types/rewards';
const { openUrl, callCoreRpc, setOAuthReturnRoute } = vi.hoisted(() => ({
const { openUrl, callCoreRpc, setOAuthReturnRoute, disconnectDiscord } = vi.hoisted(() => ({
openUrl: vi.fn(),
callCoreRpc: vi.fn(),
setOAuthReturnRoute: vi.fn(),
disconnectDiscord: vi.fn(),
}));
vi.mock('../../../utils/openUrl', () => ({ openUrl }));
vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc }));
vi.mock('../../../services/api/rewardsApi', () => ({ rewardsApi: { disconnectDiscord } }));
vi.mock('../../../utils/oauthReturnRoute', () => ({ setOAuthReturnRoute }));
function buildSnapshot(): RewardsSnapshot {
@@ -174,3 +176,134 @@ describe('RewardsCommunityTab — Connect Discord', () => {
expect(screen.queryByTestId('rewards-connect-discord')).not.toBeInTheDocument();
});
});
describe('RewardsCommunityTab — Disconnect Discord', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('disconnects Discord and refreshes the snapshot', async () => {
disconnectDiscord.mockResolvedValueOnce(undefined);
const onRetry = vi.fn();
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab
error={null}
isLoading={false}
onRetry={onRetry}
snapshot={buildSnapshot()}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByTestId('rewards-disconnect-discord'));
await waitFor(() => expect(disconnectDiscord).toHaveBeenCalledTimes(1));
// Snapshot is refetched so the connected state can flip back to Connect (re-link path).
await waitFor(() => expect(onRetry).toHaveBeenCalledTimes(1));
expect(screen.queryByTestId('rewards-disconnect-discord-error')).not.toBeInTheDocument();
});
it('surfaces an error and does not refetch when disconnect fails', async () => {
disconnectDiscord.mockRejectedValueOnce(new Error('disconnect failed'));
const onRetry = vi.fn();
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab
error={null}
isLoading={false}
onRetry={onRetry}
snapshot={buildSnapshot()}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByTestId('rewards-disconnect-discord'));
await waitFor(() =>
expect(screen.getByTestId('rewards-disconnect-discord-error')).toBeInTheDocument()
);
expect(onRetry).not.toHaveBeenCalled();
});
});
describe('RewardsCommunityTab — Discord role assignment', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows an assigned badge and the assigned-count for an in-guild member', async () => {
// buildSnapshot: member, role-1 unlocked + assigned, role-2 locked.
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={buildSnapshot()} />
</MemoryRouter>
);
expect(screen.getByTestId('rewards-role-status-role-1')).toHaveTextContent('Role assigned');
// Locked achievements have no role to claim yet, so no badge.
expect(screen.queryByTestId('rewards-role-status-role-2')).not.toBeInTheDocument();
expect(screen.getByTestId('rewards-roles-assigned')).toHaveTextContent('1 of 1 roles assigned');
// Already in the guild -> no join-to-claim prompt.
expect(screen.queryByTestId('rewards-claim-roles-banner')).not.toBeInTheDocument();
});
it('shows a pending badge when an unlocked achievement has no role assigned yet', async () => {
const snapshot = buildSnapshot();
snapshot.achievements[0].discordRoleStatus = 'not_assigned';
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={snapshot} />
</MemoryRouter>
);
expect(screen.getByTestId('rewards-role-status-role-1')).toHaveTextContent('Syncing role');
});
it('prompts a connected non-member to join the server to claim unlocked roles', async () => {
const snapshot = buildSnapshot();
snapshot.discord.membershipStatus = 'not_in_guild';
snapshot.achievements[0].discordRoleStatus = 'not_in_guild';
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={snapshot} />
</MemoryRouter>
);
expect(screen.getByTestId('rewards-claim-roles-banner')).toBeInTheDocument();
expect(screen.getByTestId('rewards-role-status-role-1')).toHaveTextContent(
'Join server to claim'
);
// The member-only assigned-count row is hidden when the user is not in the guild.
expect(screen.queryByTestId('rewards-roles-assigned')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('rewards-claim-roles-join'));
expect(openUrl).toHaveBeenCalledWith('https://discord.gg/example');
});
it('hides role-assignment status entirely when Discord is not linked', async () => {
const snapshot = buildSnapshot();
snapshot.discord = {
linked: false,
discordId: null,
username: null,
inviteUrl: 'https://discord.gg/example',
membershipStatus: 'not_linked',
};
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={snapshot} />
</MemoryRouter>
);
expect(screen.queryByTestId('rewards-role-status-role-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('rewards-claim-roles-banner')).not.toBeInTheDocument();
expect(screen.queryByTestId('rewards-roles-assigned')).not.toBeInTheDocument();
});
});
+10
View File
@@ -3181,6 +3181,9 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'جارٍ الاتصال…',
'rewards.community.cumulativeTokens': 'الرموز التراكمية',
'rewards.community.currentStreak': 'السلسلة الحالية',
'rewards.community.disconnectDiscord': 'قطع الاتصال',
'rewards.community.disconnectDiscordError': 'تعذّر قطع اتصال Discord. يُرجى المحاولة مرة أخرى.',
'rewards.community.disconnectingDiscord': 'جارٍ قطع الاتصال…',
'rewards.community.discordAccount': 'حساب Discord',
'rewards.community.discordConnected': 'تم الاتصال بـ Discord',
'rewards.community.discordConnectedAs': 'متصل باسم {username}',
@@ -3196,6 +3199,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'جارٍ تحميل المكافآت…',
'rewards.community.locked': 'مفتوح',
'rewards.community.retrying': 'جارٍ إعادة المحاولة…',
'rewards.community.roleAssigned': 'تم تعيين الرتبة',
'rewards.community.roleAssignmentCount': 'تم تعيين {assigned} من {unlocked} رتبة',
'rewards.community.roleClaimDesc':
'لقد فتحت رتب Discord لكنك لم تنضم بعد إلى خادم OpenHuman. انضم ليتم تعيينها تلقائيًا.',
'rewards.community.roleClaimTitle': 'احصل على رتب Discord الخاصة بك',
'rewards.community.roleJoinToClaim': 'انضم إلى الخادم للحصول عليها',
'rewards.community.rolePending': 'جارٍ مزامنة الرتبة…',
'rewards.community.rolesAndRewards': 'الأدوار والمكافآت',
'rewards.community.streakDays': '{n}',
'rewards.community.syncPending': 'مزامنة المكافآت معلقة',
+11
View File
@@ -3252,6 +3252,10 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'সংযুক্ত হচ্ছে…',
'rewards.community.cumulativeTokens': 'সঞ্চিত টোকেন',
'rewards.community.currentStreak': 'বর্তমান স্ট্রিক',
'rewards.community.disconnectDiscord': 'সংযোগ বিচ্ছিন্ন করুন',
'rewards.community.disconnectDiscordError':
'Discord সংযোগ বিচ্ছিন্ন করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।',
'rewards.community.disconnectingDiscord': 'সংযোগ বিচ্ছিন্ন করা হচ্ছে…',
'rewards.community.discordAccount': 'Discord অ্যাকাউন্ট',
'rewards.community.discordConnected': 'Discord সংযুক্ত',
'rewards.community.discordConnectedAs': '{username} হিসেবে সংযুক্ত',
@@ -3267,6 +3271,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'পুরস্কার লোড হচ্ছে…',
'rewards.community.locked': 'আনলক করা',
'rewards.community.retrying': 'আবার চেষ্টা হচ্ছে…',
'rewards.community.roleAssigned': 'রোল বরাদ্দ হয়েছে',
'rewards.community.roleAssignmentCount': '{unlocked}টির মধ্যে {assigned}টি রোল বরাদ্দ হয়েছে',
'rewards.community.roleClaimDesc':
'আপনি Discord রোল আনলক করেছেন কিন্তু এখনও OpenHuman সার্ভারে যোগ দেননি। যোগ দিন যাতে সেগুলি স্বয়ংক্রিয়ভাবে বরাদ্দ হয়।',
'rewards.community.roleClaimTitle': 'আপনার Discord রোল দাবি করুন',
'rewards.community.roleJoinToClaim': 'দাবি করতে সার্ভারে যোগ দিন',
'rewards.community.rolePending': 'রোল সিঙ্ক হচ্ছে…',
'rewards.community.rolesAndRewards': 'রোল ও পুরস্কার',
'rewards.community.streakDays': '{n}',
'rewards.community.syncPending': 'পুরস্কার সিঙ্ক মুলতুবি',
+11
View File
@@ -3334,6 +3334,10 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'Verbinden…',
'rewards.community.cumulativeTokens': 'Kumulierte Token',
'rewards.community.currentStreak': 'Aktuelle Serie',
'rewards.community.disconnectDiscord': 'Trennen',
'rewards.community.disconnectDiscordError':
'Discord konnte nicht getrennt werden. Bitte versuche es erneut.',
'rewards.community.disconnectingDiscord': 'Wird getrennt…',
'rewards.community.discordAccount': 'Discord-Konto',
'rewards.community.discordConnected': 'Discord verbunden',
'rewards.community.discordConnectedAs': 'Verbunden als {username}',
@@ -3350,6 +3354,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'Prämien werden geladen…',
'rewards.community.locked': 'Gesperrt',
'rewards.community.retrying': 'Erneuter Versuch…',
'rewards.community.roleAssigned': 'Rolle zugewiesen',
'rewards.community.roleAssignmentCount': '{assigned} von {unlocked} Rollen zugewiesen',
'rewards.community.roleClaimDesc':
'Du hast Discord-Rollen freigeschaltet, bist dem OpenHuman-Server aber noch nicht beigetreten. Tritt bei, damit sie automatisch zugewiesen werden.',
'rewards.community.roleClaimTitle': 'Hol dir deine Discord-Rollen',
'rewards.community.roleJoinToClaim': 'Server beitreten zum Abholen',
'rewards.community.rolePending': 'Rolle wird synchronisiert…',
'rewards.community.rolesAndRewards': 'Rollen und Belohnungen',
'rewards.community.streakDays': '{n} Tage',
'rewards.community.syncPending': 'Synchronisierung der Prämien steht aus',
+10
View File
@@ -3851,6 +3851,9 @@ const en: TranslationMap = {
'rewards.community.connectingDiscord': 'Connecting…',
'rewards.community.cumulativeTokens': 'Cumulative tokens',
'rewards.community.currentStreak': 'Current streak',
'rewards.community.disconnectDiscord': 'Disconnect',
'rewards.community.disconnectDiscordError': 'Could not disconnect Discord. Please try again.',
'rewards.community.disconnectingDiscord': 'Disconnecting…',
'rewards.community.discordAccount': 'Discord account',
'rewards.community.discordConnected': 'Discord connected',
'rewards.community.discordConnectedAs': 'Connected as {username}',
@@ -3866,6 +3869,13 @@ const en: TranslationMap = {
'rewards.community.loadingRewards': 'Loading rewards…',
'rewards.community.locked': 'Locked',
'rewards.community.retrying': 'Retrying…',
'rewards.community.roleAssigned': 'Role assigned',
'rewards.community.roleAssignmentCount': '{assigned} of {unlocked} roles assigned',
'rewards.community.roleClaimDesc':
'You unlocked Discord roles but have not joined the OpenHuman server yet. Join to get them assigned automatically.',
'rewards.community.roleClaimTitle': 'Claim your Discord roles',
'rewards.community.roleJoinToClaim': 'Join server to claim',
'rewards.community.rolePending': 'Syncing role…',
'rewards.community.rolesAndRewards': 'Roles & Rewards',
'rewards.community.streakDays': '{n}',
'rewards.community.syncPending': 'Rewards sync pending',
+10
View File
@@ -3313,6 +3313,9 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'Conectando…',
'rewards.community.cumulativeTokens': 'Tokens acumulados',
'rewards.community.currentStreak': 'Racha actual',
'rewards.community.disconnectDiscord': 'Desconectar',
'rewards.community.disconnectDiscordError': 'No se pudo desconectar Discord. Inténtalo de nuevo.',
'rewards.community.disconnectingDiscord': 'Desconectando…',
'rewards.community.discordAccount': 'Cuenta de Discord',
'rewards.community.discordConnected': 'Discord conectado',
'rewards.community.discordConnectedAs': 'Conectado como {username}',
@@ -3328,6 +3331,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'Cargando recompensas…',
'rewards.community.locked': 'Desbloqueado',
'rewards.community.retrying': 'Reintentando…',
'rewards.community.roleAssigned': 'Rol asignado',
'rewards.community.roleAssignmentCount': '{assigned} de {unlocked} roles asignados',
'rewards.community.roleClaimDesc':
'Has desbloqueado roles de Discord, pero aún no te has unido al servidor de OpenHuman. Únete para que se asignen automáticamente.',
'rewards.community.roleClaimTitle': 'Reclama tus roles de Discord',
'rewards.community.roleJoinToClaim': 'Únete al servidor para reclamar',
'rewards.community.rolePending': 'Sincronizando rol…',
'rewards.community.rolesAndRewards': 'Roles y recompensas',
'rewards.community.streakDays': '{n}',
'rewards.community.syncPending': 'Sincronización de recompensas pendiente',
+11
View File
@@ -3325,6 +3325,10 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'Connexion…',
'rewards.community.cumulativeTokens': 'Tokens cumulés',
'rewards.community.currentStreak': 'Série actuelle',
'rewards.community.disconnectDiscord': 'Déconnecter',
'rewards.community.disconnectDiscordError':
'Impossible de déconnecter Discord. Veuillez réessayer.',
'rewards.community.disconnectingDiscord': 'Déconnexion…',
'rewards.community.discordAccount': 'Compte Discord',
'rewards.community.discordConnected': 'Discord connecté',
'rewards.community.discordConnectedAs': 'Connecté en tant que {username}',
@@ -3340,6 +3344,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'Chargement des récompenses…',
'rewards.community.locked': 'Débloqué',
'rewards.community.retrying': 'Nouvelle tentative…',
'rewards.community.roleAssigned': 'Rôle attribué',
'rewards.community.roleAssignmentCount': '{assigned} sur {unlocked} rôles attribués',
'rewards.community.roleClaimDesc':
"Vous avez débloqué des rôles Discord mais n'avez pas encore rejoint le serveur OpenHuman. Rejoignez-le pour qu'ils soient attribués automatiquement.",
'rewards.community.roleClaimTitle': 'Réclamez vos rôles Discord',
'rewards.community.roleJoinToClaim': 'Rejoindre le serveur pour réclamer',
'rewards.community.rolePending': 'Synchronisation du rôle…',
'rewards.community.rolesAndRewards': 'Rôles & Récompenses',
'rewards.community.streakDays': '{n}',
'rewards.community.syncPending': 'Synchronisation des récompenses en attente',
+11
View File
@@ -3255,6 +3255,10 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'कनेक्ट हो रहा है…',
'rewards.community.cumulativeTokens': 'कुल टोकन',
'rewards.community.currentStreak': 'मौजूदा स्ट्रीक',
'rewards.community.disconnectDiscord': 'डिस्कनेक्ट करें',
'rewards.community.disconnectDiscordError':
'Discord डिस्कनेक्ट नहीं हो सका। कृपया पुनः प्रयास करें।',
'rewards.community.disconnectingDiscord': 'डिस्कनेक्ट हो रहा है…',
'rewards.community.discordAccount': 'Discord खाता',
'rewards.community.discordConnected': 'Discord कनेक्ट हो गया',
'rewards.community.discordConnectedAs': '{username} के रूप में कनेक्टेड',
@@ -3270,6 +3274,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'रिवॉर्ड लोड हो रहे हैं…',
'rewards.community.locked': 'अनलॉक्ड',
'rewards.community.retrying': 'फिर से कोशिश हो रही है…',
'rewards.community.roleAssigned': 'रोल असाइन हो गया',
'rewards.community.roleAssignmentCount': '{unlocked} में से {assigned} रोल असाइन किए गए',
'rewards.community.roleClaimDesc':
'आपने Discord रोल अनलॉक किए हैं लेकिन अभी तक OpenHuman सर्वर में शामिल नहीं हुए हैं। शामिल हों ताकि वे अपने-आप असाइन हो जाएँ।',
'rewards.community.roleClaimTitle': 'अपने Discord रोल क्लेम करें',
'rewards.community.roleJoinToClaim': 'क्लेम करने के लिए सर्वर जॉइन करें',
'rewards.community.rolePending': 'रोल सिंक हो रहा है…',
'rewards.community.rolesAndRewards': 'रोल्स और रिवॉर्ड',
'rewards.community.streakDays': '{n}',
'rewards.community.syncPending': 'रिवॉर्ड सिंक पेंडिंग',
+10
View File
@@ -3260,6 +3260,9 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'Menghubungkan…',
'rewards.community.cumulativeTokens': 'Token kumulatif',
'rewards.community.currentStreak': 'Streak saat ini',
'rewards.community.disconnectDiscord': 'Putuskan',
'rewards.community.disconnectDiscordError': 'Tidak dapat memutuskan Discord. Silakan coba lagi.',
'rewards.community.disconnectingDiscord': 'Memutuskan…',
'rewards.community.discordAccount': 'Akun Discord',
'rewards.community.discordConnected': 'Discord terhubung',
'rewards.community.discordConnectedAs': 'Terhubung sebagai {username}',
@@ -3275,6 +3278,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'Memuat hadiah...',
'rewards.community.locked': 'Terbuka',
'rewards.community.retrying': 'Mencoba ulang...',
'rewards.community.roleAssigned': 'Role ditetapkan',
'rewards.community.roleAssignmentCount': '{assigned} dari {unlocked} role ditetapkan',
'rewards.community.roleClaimDesc':
'Kamu telah membuka role Discord tetapi belum bergabung ke server OpenHuman. Bergabunglah agar role ditetapkan secara otomatis.',
'rewards.community.roleClaimTitle': 'Klaim role Discord kamu',
'rewards.community.roleJoinToClaim': 'Gabung server untuk klaim',
'rewards.community.rolePending': 'Menyinkronkan role…',
'rewards.community.rolesAndRewards': 'Peran & Hadiah',
'rewards.community.streakDays': '{n}',
'rewards.community.syncPending': 'Sinkronisasi hadiah tertunda',
+10
View File
@@ -3305,6 +3305,9 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'Connessione…',
'rewards.community.cumulativeTokens': 'Token cumulativi',
'rewards.community.currentStreak': 'Streak attuale',
'rewards.community.disconnectDiscord': 'Disconnetti',
'rewards.community.disconnectDiscordError': 'Impossibile disconnettere Discord. Riprova.',
'rewards.community.disconnectingDiscord': 'Disconnessione…',
'rewards.community.discordAccount': 'Account Discord',
'rewards.community.discordConnected': 'Discord connesso',
'rewards.community.discordConnectedAs': 'Connesso come {username}',
@@ -3320,6 +3323,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'Caricamento premi…',
'rewards.community.locked': 'Bloccato',
'rewards.community.retrying': 'Nuovo tentativo…',
'rewards.community.roleAssigned': 'Ruolo assegnato',
'rewards.community.roleAssignmentCount': '{assigned} di {unlocked} ruoli assegnati',
'rewards.community.roleClaimDesc':
'Hai sbloccato i ruoli Discord ma non ti sei ancora unito al server OpenHuman. Unisciti per assegnarli automaticamente.',
'rewards.community.roleClaimTitle': 'Riscatta i tuoi ruoli Discord',
'rewards.community.roleJoinToClaim': 'Unisciti al server per riscattare',
'rewards.community.rolePending': 'Sincronizzazione ruolo…',
'rewards.community.rolesAndRewards': 'Ruoli e premi',
'rewards.community.streakDays': '{n}',
'rewards.community.syncPending': 'Sincronizzazione premi in attesa',
+11
View File
@@ -3224,6 +3224,10 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': '연결 중…',
'rewards.community.cumulativeTokens': '누적 토큰',
'rewards.community.currentStreak': '현재 연속 기록',
'rewards.community.disconnectDiscord': '연결 해제',
'rewards.community.disconnectDiscordError':
'Discord 연결을 해제하지 못했습니다. 다시 시도해 주세요.',
'rewards.community.disconnectingDiscord': '연결 해제 중…',
'rewards.community.discordAccount': 'Discord 계정',
'rewards.community.discordConnected': 'Discord 연결됨',
'rewards.community.discordConnectedAs': '{username}(으)로 연결됨',
@@ -3240,6 +3244,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': '보상 불러오는 중…',
'rewards.community.locked': '잠김',
'rewards.community.retrying': '다시 시도 중…',
'rewards.community.roleAssigned': '역할이 부여됨',
'rewards.community.roleAssignmentCount': '{unlocked}개 중 {assigned}개 역할 부여됨',
'rewards.community.roleClaimDesc':
'Discord 역할을 잠금 해제했지만 아직 OpenHuman 서버에 참여하지 않았습니다. 참여하면 자동으로 부여됩니다.',
'rewards.community.roleClaimTitle': 'Discord 역할 받기',
'rewards.community.roleJoinToClaim': '받으려면 서버 참여',
'rewards.community.rolePending': '역할 동기화 중…',
'rewards.community.rolesAndRewards': '역할 및 보상',
'rewards.community.streakDays': '{n}일',
'rewards.community.syncPending': '보상 동기화 대기 중',
+11
View File
@@ -3301,6 +3301,10 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'Łączenie…',
'rewards.community.cumulativeTokens': 'Łączna liczba tokenów',
'rewards.community.currentStreak': 'Aktualna seria',
'rewards.community.disconnectDiscord': 'Rozłącz',
'rewards.community.disconnectDiscordError':
'Nie udało się rozłączyć konta Discord. Spróbuj ponownie.',
'rewards.community.disconnectingDiscord': 'Rozłączanie…',
'rewards.community.discordAccount': 'Konto Discord',
'rewards.community.discordConnected': 'Discord połączony',
'rewards.community.discordConnectedAs': 'Połączono jako {username}',
@@ -3317,6 +3321,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'Wczytywanie nagród…',
'rewards.community.locked': 'Zablokowane',
'rewards.community.retrying': 'Ponawianie…',
'rewards.community.roleAssigned': 'Rola przypisana',
'rewards.community.roleAssignmentCount': 'Przypisano {assigned} z {unlocked} ról',
'rewards.community.roleClaimDesc':
'Odblokowałeś role Discord, ale nie dołączyłeś jeszcze do serwera OpenHuman. Dołącz, aby zostały przypisane automatycznie.',
'rewards.community.roleClaimTitle': 'Odbierz swoje role Discord',
'rewards.community.roleJoinToClaim': 'Dołącz do serwera, aby odebrać',
'rewards.community.rolePending': 'Synchronizowanie roli…',
'rewards.community.rolesAndRewards': 'Role i nagrody',
'rewards.community.streakDays': '{n} dni',
'rewards.community.syncPending': 'Synchronizacja nagród w toku',
+11
View File
@@ -3309,6 +3309,10 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'Conectando…',
'rewards.community.cumulativeTokens': 'Tokens acumulados',
'rewards.community.currentStreak': 'Sequência atual',
'rewards.community.disconnectDiscord': 'Desconectar',
'rewards.community.disconnectDiscordError':
'Não foi possível desconectar o Discord. Tente novamente.',
'rewards.community.disconnectingDiscord': 'Desconectando…',
'rewards.community.discordAccount': 'Conta do Discord',
'rewards.community.discordConnected': 'Discord conectado',
'rewards.community.discordConnectedAs': 'Conectado como {username}',
@@ -3324,6 +3328,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'Carregando recompensas…',
'rewards.community.locked': 'Desbloqueado',
'rewards.community.retrying': 'Tentando novamente…',
'rewards.community.roleAssigned': 'Cargo atribuído',
'rewards.community.roleAssignmentCount': '{assigned} de {unlocked} cargos atribuídos',
'rewards.community.roleClaimDesc':
'Você desbloqueou cargos do Discord, mas ainda não entrou no servidor do OpenHuman. Entre para que sejam atribuídos automaticamente.',
'rewards.community.roleClaimTitle': 'Resgate seus cargos do Discord',
'rewards.community.roleJoinToClaim': 'Entre no servidor para resgatar',
'rewards.community.rolePending': 'Sincronizando cargo…',
'rewards.community.rolesAndRewards': 'Funções e Recompensas',
'rewards.community.streakDays': '{n}',
'rewards.community.syncPending': 'Sincronização de recompensas pendente',
+10
View File
@@ -3281,6 +3281,9 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': 'Подключение…',
'rewards.community.cumulativeTokens': 'Токенов всего',
'rewards.community.currentStreak': 'Текущая серия',
'rewards.community.disconnectDiscord': 'Отключить',
'rewards.community.disconnectDiscordError': 'Не удалось отключить Discord. Попробуйте ещё раз.',
'rewards.community.disconnectingDiscord': 'Отключение…',
'rewards.community.discordAccount': 'Аккаунт Discord',
'rewards.community.discordConnected': 'Discord подключён',
'rewards.community.discordConnectedAs': 'Подключено как {username}',
@@ -3296,6 +3299,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': 'Загрузка наград…',
'rewards.community.locked': 'Разблокировано',
'rewards.community.retrying': 'Повтор…',
'rewards.community.roleAssigned': 'Роль назначена',
'rewards.community.roleAssignmentCount': 'Назначено ролей: {assigned} из {unlocked}',
'rewards.community.roleClaimDesc':
'Вы разблокировали роли Discord, но ещё не присоединились к серверу OpenHuman. Присоединитесь, чтобы они назначились автоматически.',
'rewards.community.roleClaimTitle': 'Получите свои роли в Discord',
'rewards.community.roleJoinToClaim': 'Присоединиться к серверу',
'rewards.community.rolePending': 'Синхронизация роли…',
'rewards.community.rolesAndRewards': 'Роли и награды',
'rewards.community.streakDays': '{n}',
'rewards.community.syncPending': 'Синхронизация наград ожидает',
+10
View File
@@ -3093,6 +3093,9 @@ const messages: TranslationMap = {
'rewards.community.connectingDiscord': '正在连接…',
'rewards.community.cumulativeTokens': '累计令牌',
'rewards.community.currentStreak': '当前连续天数',
'rewards.community.disconnectDiscord': '断开连接',
'rewards.community.disconnectDiscordError': '无法断开 Discord 连接,请重试。',
'rewards.community.disconnectingDiscord': '正在断开连接…',
'rewards.community.discordAccount': 'Discord 账户',
'rewards.community.discordConnected': 'Discord 已连接',
'rewards.community.discordConnectedAs': '已连接为 {username}',
@@ -3108,6 +3111,13 @@ const messages: TranslationMap = {
'rewards.community.loadingRewards': '正在加载奖励…',
'rewards.community.locked': '已解锁',
'rewards.community.retrying': '重试中…',
'rewards.community.roleAssigned': '身份组已分配',
'rewards.community.roleAssignmentCount': '已分配 {assigned}/{unlocked} 个身份组',
'rewards.community.roleClaimDesc':
'你已解锁 Discord 身份组,但尚未加入 OpenHuman 服务器。加入后将自动为你分配。',
'rewards.community.roleClaimTitle': '领取你的 Discord 身份组',
'rewards.community.roleJoinToClaim': '加入服务器领取',
'rewards.community.rolePending': '正在同步身份组…',
'rewards.community.rolesAndRewards': '角色与奖励',
'rewards.community.streakDays': '{n} 天',
'rewards.community.syncPending': '奖励同步待处理',
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { normalizeRewardsApiError, normalizeRewardsSnapshot, rewardsApi } from '../rewardsApi';
vi.mock('../../apiClient', () => ({ apiClient: { get: vi.fn() } }));
vi.mock('../../apiClient', () => ({ apiClient: { get: vi.fn(), delete: vi.fn() } }));
describe('normalizeRewardsSnapshot', () => {
it('normalizes a backend rewards payload', () => {
@@ -153,6 +153,50 @@ describe('rewardsApi', () => {
});
});
describe('rewardsApi.disconnectDiscord', () => {
it('resolves when the backend returns success', async () => {
const { apiClient } = await import('../../apiClient');
vi.mocked(apiClient.delete).mockResolvedValueOnce({ success: true, data: null });
await expect(rewardsApi.disconnectDiscord()).resolves.toBeUndefined();
expect(apiClient.delete).toHaveBeenCalledWith('/rewards/discord', { timeout: 15000 });
});
it('throws a normalized error on transport failure', async () => {
const { apiClient } = await import('../../apiClient');
vi.mocked(apiClient.delete).mockRejectedValueOnce(new Error('network error'));
await expect(rewardsApi.disconnectDiscord()).rejects.toMatchObject({
success: false,
error: 'network error',
});
});
it('throws a RewardsApiError when the backend reports failure', async () => {
const { apiClient } = await import('../../apiClient');
vi.mocked(apiClient.delete).mockResolvedValueOnce({
success: false,
data: null,
error: 'Unable to disconnect Discord',
});
await expect(rewardsApi.disconnectDiscord()).rejects.toMatchObject({
success: false,
error: 'Unable to disconnect Discord',
});
});
it('falls back to a default message when backend error has no message', async () => {
const { apiClient } = await import('../../apiClient');
vi.mocked(apiClient.delete).mockResolvedValueOnce({ success: false, data: null });
await expect(rewardsApi.disconnectDiscord()).rejects.toMatchObject({
success: false,
error: 'Unable to disconnect Discord',
});
});
});
describe('normalizeRewardsApiError', () => {
it('keeps useful backend errors intact', () => {
expect(normalizeRewardsApiError({ error: 'Rewards service unavailable' })).toEqual({
+24
View File
@@ -191,4 +191,28 @@ export const rewardsApi = {
);
return normalizeRewardsSnapshot(response.data);
},
async disconnectDiscord(): Promise<void> {
let response: ApiResponse<unknown>;
try {
response = await apiClient.delete<ApiResponse<unknown>>('/rewards/discord', {
timeout: REWARDS_SNAPSHOT_TIMEOUT_MS,
});
} catch (transportError) {
const normalized = normalizeRewardsApiError(transportError);
log('disconnect transport failed error=%s', normalized.error);
throw normalized;
}
if (!response.success) {
const appError: RewardsApiError = {
success: false,
error: response.error ?? response.message ?? 'Unable to disconnect Discord',
};
log('disconnect backend error error=%s', appError.error);
throw appError;
}
log('discord disconnected');
},
};
+23 -10
View File
@@ -81,7 +81,14 @@ export async function waitForHomePage(timeout = 15_000) {
// Home page (Home.tsx) renders t('home.askAssistant') = 'Ask your assistant anything...'
// as a stable CTA button. The animated typewriter heading ('Welcome, <name> 👋' etc.)
// and old strings ('Good morning', 'Message OpenHuman', 'Upgrade to Premium') are gone.
const candidates = ['Ask your assistant anything', 'Your device is connected'];
// After the /home → /chat redirect (AppRoutes.tsx), the chat new-window hero renders
// t('home.statusOk') instead, so include both the old CTA text and the new status copy.
const candidates = [
'Ask your assistant anything',
'Your device is connected',
'Your assistant is ready when you are',
'Type something below to get started',
];
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
for (const text of candidates) {
@@ -122,14 +129,15 @@ export async function clickFirstMatch(candidates, timeout = 5_000) {
* Appium Mac2 cannot run W3C Execute Script in WKWebView — use sidebar labels
* instead.
*
* Current IA (bottom-tab bar, see app/src/config/navConfig.ts): the six tabs
* are Home, Chat, Human, Brain, Connections, Settings. The earlier
* Current IA (bottom-tab bar, see app/src/config/navConfig.ts): the four tabs
* are Chat, Human, Brain, Connections. Settings is reached via the gear icon in
* the sidebar header. Home no longer has its own tab (it was merged into Chat in
* Phase 6 — /home redirects to /chat via HASH_REDIRECTS below). The earlier
* "Assistant"/"Activity"/"Alerts" labels are gone. Only real tabs belong here;
* routes that redirect (e.g. /activity, /intelligence, /skills, /channels) are
* resolved through HASH_REDIRECTS below — they have no sidebar button.
* routes that redirect (e.g. /home, /activity, /intelligence, /skills, /channels)
* are resolved through HASH_REDIRECTS below — they have no sidebar button.
*/
const HASH_TO_SIDEBAR_LABEL = {
'/home': 'Home',
'/chat': 'Chat',
'/human': 'Human',
'/brain': 'Brain',
@@ -144,6 +152,7 @@ const HASH_TO_SIDEBAR_LABEL = {
* app/src/AppRoutes.tsx.
*/
const HASH_REDIRECTS = {
'/home': '/chat',
'/skills': '/connections',
'/channels': '/connections',
'/activity': '/settings/notifications',
@@ -339,7 +348,8 @@ export async function navigateViaHash(hash) {
return;
}
const label = HASH_TO_SIDEBAR_LABEL[normalized];
// Resolve redirect before label lookup so that e.g. /home → Chat works on Mac2.
const label = HASH_TO_SIDEBAR_LABEL[resolveRedirect(normalized)];
if (label) {
try {
await clickText(label, 12_000);
@@ -357,20 +367,23 @@ export async function navigateViaHash(hash) {
}
export async function navigateToHome() {
await navigateViaHash('/home');
// /home redirects to /chat (AppRoutes.tsx). Navigate directly to /chat so
// the sidebar button click path uses the 'Chat' label which exists, rather
// than 'Home' which no longer has a dedicated tab.
await navigateViaHash('/chat');
const homeText = await waitForHomePage(10_000);
if (!homeText) {
if (supportsExecuteScript()) {
try {
await browser.execute(() => {
window.location.hash = '/home';
window.location.hash = '/chat';
});
} catch {
/* ignore */
}
} else {
try {
await clickText('Home', 8_000);
await clickText('Chat', 8_000);
} catch {
/* ignore */
}
@@ -34,10 +34,21 @@ interface RouteCheck {
const ROUTES: RouteCheck[] = [
// Chat composer header: "New" thread button, agent-profile "Reasoning" pill.
{ hash: '/chat', markers: ['New', 'Chat', 'Message', 'Reasoning'] },
{ hash: '/chat', markers: ['New', 'Chat', 'Message', 'Reasoning', 'Your assistant is ready'] },
// Connections page (was /skills) — tabs: Apps, Messaging, Tools, Explorer
{ hash: '/connections', markers: ['Apps', 'Messaging', 'Tools', 'Connections'] },
{ hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected', 'Home'] },
// /home redirects to /chat (AppRoutes.tsx Phase 6); markers cover both old
// Home.tsx CTA text and the new chat new-window hero status copy.
{
hash: '/home',
markers: [
'Ask your assistant anything',
'Your device is connected',
'Your assistant is ready',
'Home',
'Chat',
],
},
{
hash: '/notifications',
markers: ['Notifications', 'Alerts', 'Notification', 'No notifications'],
@@ -48,7 +59,16 @@ const ROUTES: RouteCheck[] = [
// Subconscious surface and memory live here now). Tabs: Graph, Memory,
// Sources, Subconscious, Sync.
{ hash: '/brain', markers: ['Graph', 'Memory', 'Subconscious', 'Sources'] },
{ hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected', 'Home'] },
{
hash: '/home',
markers: [
'Ask your assistant anything',
'Your device is connected',
'Your assistant is ready',
'Home',
'Chat',
],
},
];
async function rootTextLength(): Promise<number> {
@@ -129,14 +149,16 @@ describe('Navigation smoothness', () => {
console.log(`${LOG_PREFIX} N1.2: passed — rapid cycle complete`);
});
it('N1.3 — final state is /home with correct content', async () => {
it('N1.3 — final state is /home (or /chat redirect) with correct content', async () => {
console.log(`${LOG_PREFIX} N1.3: navigating to /home for final check`);
await navigateViaHash('/home');
const homeText = await waitForHomePage(ROUTE_TIMEOUT);
expect(homeText).toBeTruthy();
// AppRoutes.tsx redirects /home → /chat, so the settled hash is #/chat.
// Accept either form to keep the test resilient across routing changes.
const hash = await browser.execute(() => window.location.hash);
expect(hash).toMatch(/^#\/home/);
console.log(`${LOG_PREFIX} N1.3: passed — on /home, content: "${homeText}"`);
expect(hash).toMatch(/^#\/(home|chat)(\/|$)/);
console.log(`${LOG_PREFIX} N1.3: passed — on ${hash}, content: "${homeText}"`);
});
});
+5 -1
View File
@@ -29,11 +29,15 @@ interface Route {
}
// Phase 2/3/6 IA revamp:
// /home → /chat (Phase 6 — /home is now the merged chat surface)
// /human → /chat (Phase 6 — back-compat redirect)
// /skills → /connections (Phase 2 — back-compat redirect)
// /intelligence → /activity (Phase 3 — back-compat redirect)
// Note: /home is intentionally omitted here because AppRoutes.tsx redirects it
// to /chat — navigateViaHash('/home') settles on #/chat, which is covered by
// the /chat row. Keeping /home in ROUTES would cause the hash assertion to
// fail since the actual hash is #/chat, not #/home.
const ROUTES: Route[] = [
{ hash: '/home' },
{ hash: '/chat' },
{ hash: '/connections' },
{ hash: '/activity' },
@@ -37,9 +37,13 @@ describe('Webhooks ingress surface (stub-level)', () => {
it('reaches the app shell after onboarding', async () => {
// Home.tsx: t('home.askAssistant') is the stable home page CTA button text.
// After the /home → /chat redirect (AppRoutes.tsx), the chat new-window hero
// renders t('home.statusOk') instead of the old CTA button.
const atHome =
(await textExists('Ask your assistant anything')) ||
(await textExists('Your device is connected'));
(await textExists('Your device is connected')) ||
(await textExists('Your assistant is ready when you are')) ||
(await textExists('Type something below to get started'));
expect(atHome).toBe(true);
});
@@ -82,9 +82,13 @@ describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => {
it('reached the logged-in shell after onboarding', async () => {
// Home.tsx: t('home.askAssistant') is the stable home page CTA button text.
// After the /home → /chat redirect (AppRoutes.tsx), the chat new-window hero
// renders t('home.statusOk') instead of the old CTA button.
const atHome =
(await textExists('Ask your assistant anything')) ||
(await textExists('Your device is connected'));
(await textExists('Your device is connected')) ||
(await textExists('Your assistant is ready when you are')) ||
(await textExists('Type something below to get started'));
expect(atHome).toBe(true);
});
+2 -2
View File
@@ -52,7 +52,7 @@ test.describe('Login Flow', () => {
await expect
.poll(async () => page.evaluate(() => window.location.hash))
.toMatch(/^#\/(home|chat)/);
.toMatch(/^#\/(home|chat)(\/|$)/);
await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy();
});
@@ -61,7 +61,7 @@ test.describe('Login Flow', () => {
await expect
.poll(async () => page.evaluate(() => window.location.hash))
.toMatch(/^#\/(home|chat)/);
.toMatch(/^#\/(home|chat)(\/|$)/);
const consumeCall = (await requests()).find(
request => request.method === 'POST' && request.url.includes('/telegram/login-tokens/')
@@ -112,7 +112,7 @@ test.describe('Runtime picker -> login -> logout', () => {
await expect
.poll(async () => page.evaluate(() => window.location.hash))
.toMatch(/^#\/(home|chat)/);
.toMatch(/^#\/(home|chat)(\/|$)/);
await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy();
await page.goto('/#/settings/account');