mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix: repair pre-existing failing tests + fmt drift on main (#3623)
This commit is contained in:
@@ -125,3 +125,4 @@ distribution.cer
|
||||
|
||||
# Release note previews
|
||||
CHANGELOG.preview.md
|
||||
*.profraw
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { type MascotFace, RiveMascot } from '../../features/human/Mascot';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
@@ -36,14 +36,39 @@ interface Props {
|
||||
onToast?: (toast: Toast) => void;
|
||||
}
|
||||
|
||||
interface MeetingBotsInlineProps extends Props {
|
||||
hasSubmittedRef: RefObject<boolean>;
|
||||
}
|
||||
|
||||
export default function MeetingBotsCard({ onToast }: Props) {
|
||||
const { t } = useT();
|
||||
const status = useAppSelector(selectBackendMeetStatus);
|
||||
const showActive = status === 'active';
|
||||
|
||||
// `hasSubmittedRef` lives in this always-mounted parent so the success toast
|
||||
// fires reliably. When a join succeeds, `status` flips to 'active' and this
|
||||
// component swaps `MeetingBotsInline` → `ActiveMeetingView`, unmounting the
|
||||
// inline form before any effect inside it could observe 'active' (#3611
|
||||
// flattened these into a mutually-exclusive ternary). The inline form sets
|
||||
// this ref on submit; we fire the success toast here. The error path stays in
|
||||
// the inline form, which remains mounted during the 'error' state.
|
||||
const hasSubmittedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasSubmittedRef.current) return;
|
||||
if (status === 'active') {
|
||||
hasSubmittedRef.current = false;
|
||||
onToast?.({
|
||||
type: 'success',
|
||||
title: t('skills.meetingBots.joiningTitle'),
|
||||
message: t('skills.meetingBots.joiningMessage'),
|
||||
});
|
||||
}
|
||||
}, [status, onToast, t]);
|
||||
|
||||
return showActive ? (
|
||||
<ActiveMeetingView onToast={onToast} />
|
||||
) : (
|
||||
<MeetingBotsInline onToast={onToast} />
|
||||
<MeetingBotsInline onToast={onToast} hasSubmittedRef={hasSubmittedRef} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +198,7 @@ function ActiveMeetingView({ onToast }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function MeetingBotsInline({ onToast }: Props) {
|
||||
function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const [meetUrl, setMeetUrl] = useState('');
|
||||
@@ -188,7 +213,6 @@ function MeetingBotsInline({ onToast }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const meetStatus = useAppSelector(selectBackendMeetStatus);
|
||||
const meetError = useAppSelector(selectBackendMeetError);
|
||||
const hasSubmittedRef = useRef(false);
|
||||
const [recentCalls, setRecentCalls] = useState<MeetCallRecord[] | null>(null);
|
||||
const [recentError, setRecentError] = useState<string | null>(null);
|
||||
|
||||
@@ -218,18 +242,12 @@ function MeetingBotsInline({ onToast }: Props) {
|
||||
? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor }
|
||||
: undefined;
|
||||
|
||||
// Success ('active') is handled by the parent MeetingBotsCard, which stays
|
||||
// mounted across the inline→active view swap. The error path lives here
|
||||
// because the inline form remains mounted during the 'error' state and needs
|
||||
// to surface the failure inline (setError/setSubmitting) alongside the toast.
|
||||
useEffect(() => {
|
||||
if (!hasSubmittedRef.current) return;
|
||||
if (meetStatus === 'active') {
|
||||
hasSubmittedRef.current = false;
|
||||
onToast?.({
|
||||
type: 'success',
|
||||
title: t('skills.meetingBots.joiningTitle'),
|
||||
message: t('skills.meetingBots.joiningMessage'),
|
||||
});
|
||||
setMeetUrl('');
|
||||
return;
|
||||
}
|
||||
if (meetStatus === 'error') {
|
||||
hasSubmittedRef.current = false;
|
||||
const message = meetError?.trim() || t('skills.meetingBots.failedToStart');
|
||||
@@ -237,7 +255,7 @@ function MeetingBotsInline({ onToast }: Props) {
|
||||
setSubmitting(false);
|
||||
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message });
|
||||
}
|
||||
}, [meetStatus, meetError, onToast, t]);
|
||||
}, [meetStatus, meetError, onToast, t, hasSubmittedRef]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Tests for the Phase 6 face-mode toggle in the Accounts (Assistant) page.
|
||||
*
|
||||
* Verifies:
|
||||
* - Face toggle button is rendered
|
||||
* - Face mode is off by default
|
||||
* - Clicking the toggle shows the face-mode panel (data-testid="face-mode-panel")
|
||||
* - Clicking the toggle again hides the face-mode panel
|
||||
* - Face mode state is persisted to localStorage (chat.faceMode)
|
||||
* - When face mode is on, Conversations is rendered with variant="sidebar"
|
||||
*/
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import accountsReducer from '../../store/accountsSlice';
|
||||
import chatRuntimeReducer from '../../store/chatRuntimeSlice';
|
||||
import mascotReducer from '../../store/mascotSlice';
|
||||
import threadReducer from '../../store/threadSlice';
|
||||
// ── Static component import (after mocks are hoisted) ───────────────────────
|
||||
import Accounts from '../Accounts';
|
||||
|
||||
// ── Heavy dependency stubs — all must be declared before the component import ──
|
||||
|
||||
// Stub Conversations so it doesn't pull in the full chat stack.
|
||||
vi.mock('../Conversations', () => ({
|
||||
default: ({ variant }: { variant?: string }) => (
|
||||
<div data-testid="conversations-stub" data-variant={variant ?? 'page'} />
|
||||
),
|
||||
AgentChatPanel: () => <div data-testid="agent-chat-panel-stub" />,
|
||||
}));
|
||||
|
||||
// Stub webview account components.
|
||||
vi.mock('../../components/accounts/WebviewHost', () => ({
|
||||
default: () => <div data-testid="webview-host-stub" />,
|
||||
}));
|
||||
vi.mock('../../components/accounts/AddAccountModal', () => ({ default: () => null }));
|
||||
vi.mock('../../components/accounts/providerIcons', () => ({
|
||||
AgentIcon: ({ className }: { className?: string }) => (
|
||||
<svg data-testid="agent-icon" className={className} />
|
||||
),
|
||||
ProviderIcon: ({ provider, className }: { provider: string; className?: string }) => (
|
||||
<svg data-testid={`provider-icon-${provider}`} className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
// Stub webview account service.
|
||||
vi.mock('../../services/webviewAccountService', () => ({
|
||||
startWebviewAccountService: vi.fn(),
|
||||
hideWebviewAccount: vi.fn(),
|
||||
showWebviewAccount: vi.fn(),
|
||||
purgeWebviewAccount: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/analytics', () => ({ trackEvent: vi.fn() }));
|
||||
|
||||
// Stub mascot subcomponents — they pull in a Rive WASM runtime.
|
||||
vi.mock('../../features/human/Mascot', () => ({
|
||||
RiveMascot: () => <div data-testid="rive-mascot-stub" />,
|
||||
CustomGifMascot: ({ src }: { src: string }) => (
|
||||
<img data-testid="custom-gif-mascot-stub" src={src} alt="" />
|
||||
),
|
||||
MascotChipAvatar: () => <span data-testid="mascot-chip-avatar-stub" />,
|
||||
getMascotPalette: vi.fn(() => ({ bodyFill: '#4A83DD', neckShadowColor: '#2A63BD' })),
|
||||
hexToArgbInt: vi.fn((_hex: string) => 0xff4a83dd),
|
||||
}));
|
||||
|
||||
vi.mock('../../features/human/useHumanMascot', () => ({
|
||||
useHumanMascot: () => ({ face: 'idle', visemeCode: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/usePrewarmMostRecentAccount', () => ({
|
||||
usePrewarmMostRecentAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const FACE_MODE_KEY = 'chat.faceMode';
|
||||
|
||||
function buildStore() {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
accounts: accountsReducer,
|
||||
mascot: mascotReducer,
|
||||
thread: threadReducer,
|
||||
chatRuntime: chatRuntimeReducer,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderAccounts(store = buildStore()) {
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<Accounts />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Accounts — face-mode toggle', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders the face-toggle button', () => {
|
||||
renderAccounts();
|
||||
expect(screen.getByTestId('face-toggle-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('face mode is off by default (no face-mode-panel rendered)', () => {
|
||||
renderAccounts();
|
||||
expect(screen.queryByTestId('face-mode-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the toggle shows the face-mode panel', async () => {
|
||||
renderAccounts();
|
||||
const toggle = screen.getByTestId('face-toggle-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
});
|
||||
expect(screen.getByTestId('face-mode-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the toggle again hides the face-mode panel', async () => {
|
||||
renderAccounts();
|
||||
const toggle = screen.getByTestId('face-toggle-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
});
|
||||
expect(screen.getByTestId('face-mode-panel')).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('face-toggle-button'));
|
||||
});
|
||||
expect(screen.queryByTestId('face-mode-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists face-mode ON to localStorage', async () => {
|
||||
renderAccounts();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('face-toggle-button'));
|
||||
});
|
||||
expect(localStorage.getItem(FACE_MODE_KEY)).toBe('1');
|
||||
});
|
||||
|
||||
it('persists face-mode OFF to localStorage after toggling twice', async () => {
|
||||
renderAccounts();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('face-toggle-button'));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('face-toggle-button'));
|
||||
});
|
||||
expect(localStorage.getItem(FACE_MODE_KEY)).toBe('0');
|
||||
});
|
||||
|
||||
it('reads face-mode ON from localStorage on mount', () => {
|
||||
localStorage.setItem(FACE_MODE_KEY, '1');
|
||||
renderAccounts();
|
||||
expect(screen.getByTestId('face-mode-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('when face mode is on, Conversations is rendered with variant="sidebar"', async () => {
|
||||
renderAccounts();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('face-toggle-button'));
|
||||
});
|
||||
const conversations = screen.getByTestId('conversations-stub');
|
||||
expect(conversations).toHaveAttribute('data-variant', 'sidebar');
|
||||
});
|
||||
});
|
||||
@@ -542,31 +542,8 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Covers lines 1455-1483: quota pill loading state
|
||||
it('renders "Loading…" quota pill when isLoadingBudget=true', async () => {
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage: null,
|
||||
currentPlan: null,
|
||||
currentTier: 'FREE' as const,
|
||||
isFreeTier: true,
|
||||
usagePct: 0.0,
|
||||
isNearLimit: false,
|
||||
isAtLimit: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: true,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Covers lines 1417-1439: budget banner + lines 1455-1516: LimitPill + tooltip
|
||||
it('renders budget-limit banner and limit pills when teamUsage is present', async () => {
|
||||
// Covers lines 1417-1439: budget-exceeded banner
|
||||
it('renders budget-limit banner when teamUsage is present', async () => {
|
||||
// cycleBudgetUsd: 0 → renders "Your included budget is complete" branch
|
||||
const teamUsage = { cycleBudgetUsd: 0, remainingUsd: 0, cycleSpentUsd: 0, cycleEndsAt: null };
|
||||
|
||||
@@ -591,9 +568,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
// Budget-exceeded banner (lines 1417-1439) — cycleBudgetUsd=0 gives "included budget" message
|
||||
expect(screen.getByText(/Your included budget is complete/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Use OpenRouter free models/i })).toBeInTheDocument();
|
||||
|
||||
// LimitPill renders with the cycle label
|
||||
expect(screen.getByText('Cycle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Covers line 247: if (cancelled) return — the non-cancelled path through loadThreads callback
|
||||
@@ -1573,64 +1547,6 @@ describe('Conversations — worker thread back-to-parent navigation (#1624)', ()
|
||||
// cycleBudgetUsd=0 → false branch of cycleBudgetUsd > 0 ternary → budgetComplete key
|
||||
expect(screen.getByText(/Your included budget is complete/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Covers line 1910: cycleEndsAt truthy branch inside cycle-pill tooltip
|
||||
it('renders reset time in cycle-pill tooltip when cycleEndsAt is set', async () => {
|
||||
const teamUsage = {
|
||||
cycleBudgetUsd: 10,
|
||||
remainingUsd: 5,
|
||||
cycleSpentUsd: 5,
|
||||
cycleEndsAt: '2026-06-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage,
|
||||
currentPlan: null,
|
||||
currentTier: 'PRO' as const,
|
||||
isFreeTier: false,
|
||||
usagePct: 0.5,
|
||||
isNearLimit: false,
|
||||
isAtLimit: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Tooltip is hidden via CSS but present in DOM; cycleEndsAt truthy → reset span renders
|
||||
expect(screen.getByText('Cycle')).toBeInTheDocument();
|
||||
// The tooltip resets span contains "resets" text (covers line 1910 conditional)
|
||||
const resetSpans = document.querySelectorAll('[class*="text-stone-400"]');
|
||||
expect(resetSpans.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Covers lines 1903-1904: loading animation span when isLoading=true, teamUsage=null
|
||||
it('renders loading pulse span in cycle-pill area when isLoading=true', async () => {
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage: null,
|
||||
currentPlan: null,
|
||||
currentTier: 'FREE' as const,
|
||||
isFreeTier: true,
|
||||
usagePct: 0,
|
||||
isNearLimit: false,
|
||||
isAtLimit: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: true,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Loading span with animate-pulse is present when teamUsage=null and loading
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conversations — thread title editing', () => {
|
||||
|
||||
@@ -143,7 +143,10 @@ async function assertSessionNotNuked(page: Page) {
|
||||
};
|
||||
const snapshot = win.__OPENHUMAN_CORE_STATE__?.()?.snapshot;
|
||||
return {
|
||||
hash: window.location.hash,
|
||||
// The connections page now appends an active-tab query (e.g.
|
||||
// `#/connections?tab=composio`); strip it so we assert we're still on
|
||||
// the connections route (session not nuked), not the exact sub-tab.
|
||||
hash: window.location.hash.replace(/\?.*$/, ''),
|
||||
hasToken: Boolean(snapshot?.sessionToken),
|
||||
hasUser: Boolean(snapshot?.currentUser?._id),
|
||||
};
|
||||
|
||||
@@ -95,7 +95,10 @@ async function assertSessionAlive(page: Page): Promise<void> {
|
||||
}
|
||||
).__OPENHUMAN_CORE_STATE__?.()?.snapshot;
|
||||
return {
|
||||
hash: window.location.hash,
|
||||
// The connections page now appends an active-tab query (e.g.
|
||||
// `#/connections?tab=composio`); strip it so we assert we're still on
|
||||
// the connections route (session not nuked), not the exact sub-tab.
|
||||
hash: window.location.hash.replace(/\?.*$/, ''),
|
||||
hasUser: Boolean(snapshot?.currentUser?._id),
|
||||
hasToken: Boolean(snapshot?.sessionToken),
|
||||
};
|
||||
|
||||
@@ -667,8 +667,19 @@ async fn auto_save_stores_messages_in_memory() {
|
||||
|
||||
let _ = agent.turn("Remember this fact").await.unwrap();
|
||||
|
||||
// Both user message and assistant response should be saved
|
||||
let count = mem.count().await.unwrap();
|
||||
// Both user message and assistant response should be saved. The assistant
|
||||
// reply is persisted synchronously, but the user message is saved
|
||||
// fire-and-forget (tokio::spawn in turn/core.rs, #3610), so it may land a
|
||||
// moment after `turn()` returns — poll briefly instead of reading once,
|
||||
// which otherwise races on a loaded CI runner under llvm-cov instrumentation.
|
||||
let mut count = 0;
|
||||
for _ in 0..50 {
|
||||
count = mem.count().await.unwrap();
|
||||
if count >= 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(
|
||||
count >= 2,
|
||||
"Expected at least 2 memory entries, got {count}"
|
||||
|
||||
@@ -677,9 +677,7 @@ fn capture_on_thread(
|
||||
let device = host
|
||||
.default_input_device()
|
||||
.ok_or_else(|| "no default audio input device".to_string())?;
|
||||
let device_name = device
|
||||
.name()
|
||||
.unwrap_or_else(|e| format!("<unknown: {e}>"));
|
||||
let device_name = device.name().unwrap_or_else(|e| format!("<unknown: {e}>"));
|
||||
let supported = device
|
||||
.default_input_config()
|
||||
.map_err(|e| format!("no default input config: {e}"))?;
|
||||
|
||||
Reference in New Issue
Block a user