diff --git a/Cargo.lock b/Cargo.lock index e11f792f0..170f8578a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4541,7 +4541,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.53.1" +version = "0.53.3" dependencies = [ "aes-gcm", "anyhow", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 15f2f9b20..f79337ff0 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.53.1" +version = "0.53.3" dependencies = [ "anyhow", "async-trait", diff --git a/app/src/components/intelligence/__tests__/utils.test.ts b/app/src/components/intelligence/__tests__/utils.test.ts new file mode 100644 index 000000000..9dd7b8a42 --- /dev/null +++ b/app/src/components/intelligence/__tests__/utils.test.ts @@ -0,0 +1,183 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import type { ActionableItem } from '../../../types/intelligence'; +import { filterItems, getItemStats, groupItemsByTime } from '../utils'; + +// Pin the wall clock so day-boundary buckets are stable across the day and on CI. +const FIXED_NOW = new Date('2026-04-27T12:00:00.000Z'); + +beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +function makeItem( + partial: Partial & { id: string; createdAt: Date } +): ActionableItem { + return { + title: partial.title ?? 'Untitled', + description: partial.description, + source: partial.source ?? 'system', + priority: partial.priority ?? 'normal', + status: partial.status ?? 'active', + updatedAt: partial.updatedAt ?? partial.createdAt, + actionable: partial.actionable ?? false, + sourceLabel: partial.sourceLabel, + expiresAt: partial.expiresAt, + ...partial, + } as ActionableItem; +} + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +function daysAgo(n: number): Date { + return new Date(Date.now() - n * DAY); +} + +describe('intelligence/utils — actionable item helpers (11.1.2)', () => { + describe('groupItemsByTime', () => { + it('buckets items into Today / Yesterday / This Week / Older', () => { + const items = [ + makeItem({ id: 'today', createdAt: new Date() }), + makeItem({ id: 'yest', createdAt: daysAgo(1) }), + makeItem({ id: 'wk', createdAt: daysAgo(3) }), + makeItem({ id: 'old', createdAt: daysAgo(30) }), + ]; + + const groups = groupItemsByTime(items); + const labels = groups.map(g => g.label); + expect(labels).toEqual(['Today', 'Yesterday', 'This Week', 'Older']); + + const find = (label: string) => groups.find(g => g.label === label); + expect(find('Today')?.items.map(i => i.id)).toEqual(['today']); + expect(find('Yesterday')?.items.map(i => i.id)).toEqual(['yest']); + expect(find('This Week')?.items.map(i => i.id)).toEqual(['wk']); + expect(find('Older')?.items.map(i => i.id)).toEqual(['old']); + }); + + it('omits empty buckets and orders within a bucket by priority then recency', () => { + const items = [ + makeItem({ id: 'a', createdAt: new Date(), priority: 'normal' }), + makeItem({ id: 'b', createdAt: new Date(Date.now() - 2 * HOUR), priority: 'critical' }), + makeItem({ id: 'c', createdAt: new Date(Date.now() - HOUR), priority: 'critical' }), + ]; + + const groups = groupItemsByTime(items); + expect(groups).toHaveLength(1); + const today = groups[0]; + expect(today.label).toBe('Today'); + // Critical first; within critical, newer first; normal last. + expect(today.items.map(i => i.id)).toEqual(['c', 'b', 'a']); + expect(today.count).toBe(3); + }); + + it('handles an empty input as an empty group list', () => { + expect(groupItemsByTime([])).toEqual([]); + }); + }); + + describe('filterItems', () => { + const items = [ + makeItem({ + id: '1', + createdAt: new Date(), + title: 'Reply to Alice', + source: 'email', + priority: 'critical', + status: 'active', + sourceLabel: 'Gmail', + }), + makeItem({ + id: '2', + createdAt: new Date(), + title: 'Standup', + source: 'calendar', + priority: 'normal', + status: 'active', + sourceLabel: 'Calendar', + }), + makeItem({ + id: '3', + createdAt: new Date(), + title: 'Reply to Bob', + source: 'email', + priority: 'important', + status: 'completed', + sourceLabel: 'Gmail', + description: 'Follow-up on the canary deployment', + }), + ]; + + it('filters by source', () => { + const out = filterItems(items, { source: 'email' }); + expect(out.map(i => i.id)).toEqual(['1', '3']); + }); + + it('filters by priority', () => { + const out = filterItems(items, { priority: 'critical' }); + expect(out.map(i => i.id)).toEqual(['1']); + }); + + it('filters by status', () => { + const out = filterItems(items, { status: 'completed' }); + expect(out.map(i => i.id)).toEqual(['3']); + }); + + it('filters by searchTerm across title, description, and sourceLabel', () => { + expect(filterItems(items, { searchTerm: 'reply' }).map(i => i.id)).toEqual(['1', '3']); + expect(filterItems(items, { searchTerm: 'canary' }).map(i => i.id)).toEqual(['3']); + expect(filterItems(items, { searchTerm: 'gmail' }).map(i => i.id)).toEqual(['1', '3']); + }); + + it('treats "all" as a no-op for source/priority/status', () => { + const out = filterItems(items, { source: 'all', priority: 'all', status: 'all' }); + expect(out.map(i => i.id)).toEqual(['1', '2', '3']); + }); + + it('returns no items when searchTerm matches nothing (failure path)', () => { + const out = filterItems(items, { searchTerm: 'definitely-not-present' }); + expect(out).toEqual([]); + }); + }); + + describe('getItemStats', () => { + it('counts totals, priorities, and sources, and flags new + expiringSoon', () => { + const items = [ + makeItem({ + id: 'fresh', + createdAt: new Date(Date.now() - 60 * 1000), // 1 minute ago + priority: 'critical', + source: 'email', + }), + makeItem({ + id: 'old', + createdAt: new Date(Date.now() - 10 * DAY), + priority: 'normal', + source: 'calendar', + }), + makeItem({ + id: 'expSoon', + createdAt: new Date(Date.now() - 2 * HOUR), + priority: 'important', + source: 'email', + expiresAt: new Date(Date.now() + 6 * HOUR), + }), + ]; + + const stats = getItemStats(items); + expect(stats.total).toBe(3); + expect(stats.byPriority.critical).toBe(1); + expect(stats.byPriority.important).toBe(1); + expect(stats.byPriority.normal).toBe(1); + expect(stats.bySource.email).toBe(2); + expect(stats.bySource.calendar).toBe(1); + expect(stats.newItems).toBe(1); + expect(stats.expiringSoon).toBe(1); + }); + }); +}); diff --git a/app/src/features/autocomplete/__tests__/useAutocompleteSkillStatus.test.tsx b/app/src/features/autocomplete/__tests__/useAutocompleteSkillStatus.test.tsx new file mode 100644 index 000000000..93f7b060f --- /dev/null +++ b/app/src/features/autocomplete/__tests__/useAutocompleteSkillStatus.test.tsx @@ -0,0 +1,84 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useCoreState } from '../../../providers/CoreStateProvider'; +import { useAutocompleteSkillStatus } from '../useAutocompleteSkillStatus'; + +vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() })); + +type AutocompleteRuntime = { + platform_supported: boolean; + running: boolean; + enabled: boolean; + last_error?: string | null; +}; + +function mockSnapshot(autocomplete: AutocompleteRuntime | null): void { + vi.mocked(useCoreState).mockReturnValue({ snapshot: { runtime: { autocomplete } } } as ReturnType< + typeof useCoreState + >); +} + +describe('useAutocompleteSkillStatus (5.2 — autocomplete settings status)', () => { + it('returns offline + Enable CTA when no runtime status is available yet', () => { + mockSnapshot(null); + const { result } = renderHook(() => useAutocompleteSkillStatus()); + expect(result.current.connectionStatus).toBe('offline'); + expect(result.current.statusLabel).toBe('Offline'); + expect(result.current.ctaLabel).toBe('Enable'); + expect(result.current.platformUnsupported).toBe(false); + }); + + it('returns Unsupported when the platform reports the runtime is unsupported', () => { + mockSnapshot({ platform_supported: false, running: false, enabled: false }); + const { result } = renderHook(() => useAutocompleteSkillStatus()); + expect(result.current.connectionStatus).toBe('offline'); + expect(result.current.statusLabel).toBe('Unsupported'); + expect(result.current.ctaLabel).toBe('Details'); + expect(result.current.platformUnsupported).toBe(true); + }); + + it('returns Active + Manage CTA when the runtime is running (overrides stale errors)', () => { + mockSnapshot({ + platform_supported: true, + running: true, + enabled: true, + last_error: 'stale: should not surface', + }); + const { result } = renderHook(() => useAutocompleteSkillStatus()); + expect(result.current.connectionStatus).toBe('connected'); + expect(result.current.statusLabel).toBe('Active'); + expect(result.current.ctaLabel).toBe('Manage'); + }); + + it('returns Error + Retry CTA when not running and an error is present', () => { + mockSnapshot({ + platform_supported: true, + running: false, + enabled: true, + last_error: 'permission denied', + }); + const { result } = renderHook(() => useAutocompleteSkillStatus()); + expect(result.current.connectionStatus).toBe('error'); + expect(result.current.statusLabel).toBe('Error'); + expect(result.current.ctaLabel).toBe('Retry'); + expect(result.current.ctaVariant).toBe('amber'); + }); + + it('returns Enabled (disconnected) + Manage when enabled but not running and no error', () => { + mockSnapshot({ platform_supported: true, running: false, enabled: true, last_error: null }); + const { result } = renderHook(() => useAutocompleteSkillStatus()); + expect(result.current.connectionStatus).toBe('disconnected'); + expect(result.current.statusLabel).toBe('Enabled'); + expect(result.current.ctaLabel).toBe('Manage'); + }); + + it('returns Disabled + Enable CTA when not enabled and not running', () => { + mockSnapshot({ platform_supported: true, running: false, enabled: false, last_error: null }); + const { result } = renderHook(() => useAutocompleteSkillStatus()); + expect(result.current.connectionStatus).toBe('offline'); + expect(result.current.statusLabel).toBe('Disabled'); + expect(result.current.ctaLabel).toBe('Enable'); + expect(result.current.ctaVariant).toBe('sage'); + }); +}); diff --git a/app/src/store/index.ts b/app/src/store/index.ts index d6091f980..4728bf476 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -72,5 +72,13 @@ export const store = configureStore({ export const persistor = persistStore(store); +// Expose the store on `window` so WDIO E2E specs can read Redux state directly +// to assert backing-state changes (see app/test/e2e/specs/*.spec.ts). The store +// holds no secrets that aren't already in the renderer's memory; this only +// surfaces the existing handle under a stable, namespaced key. +if (typeof window !== 'undefined') { + (window as unknown as { __OPENHUMAN_STORE__?: typeof store }).__OPENHUMAN_STORE__ = store; +} + export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; diff --git a/app/test/e2e/helpers/shared-flows.ts b/app/test/e2e/helpers/shared-flows.ts index 3b5530267..9897a898e 100644 --- a/app/test/e2e/helpers/shared-flows.ts +++ b/app/test/e2e/helpers/shared-flows.ts @@ -12,11 +12,41 @@ import { clickText, dumpAccessibilityTree, textExists, + waitForText, waitForWebView, waitForWindowVisible, } from './element-helpers'; import { supportsExecuteScript } from './platform'; +// --------------------------------------------------------------------------- +// Accounts page helpers +// --------------------------------------------------------------------------- + +/** + * Open the "Add Account" modal on /accounts. + * + * The "Add app" affordance is a button whose only labelled descendants are an + * SVG plus a tooltip span with `pointer-events: none`. None of the shared + * `clickButton`/`clickText` helpers can target it cleanly because the + * accessible name lives only on `aria-label`, so this helper reaches for the + * explicit selector. Tracking a follow-up `clickByAriaLabel` helper. + */ +export async function openAddAccountModal(): Promise { + const opened = await browser.execute(() => { + const buttons = Array.from(document.querySelectorAll('button')); + const addBtn = buttons.find(b => b.getAttribute('aria-label') === 'Add app'); + if (addBtn) { + addBtn.click(); + return true; + } + return false; + }); + if (!opened) { + throw new Error('Could not locate Add app button on /accounts'); + } + await waitForText('Add account', 5_000); +} + // --------------------------------------------------------------------------- // Generic helpers // --------------------------------------------------------------------------- diff --git a/app/test/e2e/specs/autocomplete-flow.spec.ts b/app/test/e2e/specs/autocomplete-flow.spec.ts new file mode 100644 index 000000000..06ae5fcdb --- /dev/null +++ b/app/test/e2e/specs/autocomplete-flow.spec.ts @@ -0,0 +1,96 @@ +import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; +import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers'; +import { + textExists, + waitForText, + waitForWebView, + waitForWindowVisible, +} from '../helpers/element-helpers'; +import { supportsExecuteScript } from '../helpers/platform'; +import { completeOnboardingIfVisible, navigateViaHash } from '../helpers/shared-flows'; +import { startMockServer, stopMockServer } from '../mock-server'; + +/** + * Autocomplete settings panel smoke spec — narrow scope. + * + * What this spec proves: the AutocompletePanel mounts under /settings, + * the skill-status pill renders one of the canonical labels surfaced by + * `useAutocompleteSkillStatus`, and the matching CTA renders. That is + * the entire claim — this spec does NOT exercise: + * - 5.2.1 inline suggestion generation (requires real keystrokes inside + * a third-party text field + macOS Accessibility + Input Monitoring + * TCC grants — see manual smoke checklist #971) + * - 5.2.2 debounce timing (covered by the Vitest hook test in + * `app/src/features/autocomplete/__tests__/useAutocompleteSkillStatus.test.tsx` + * for the status surface; debounce of the engine itself is a Rust + * unit test concern) + * - 5.2.3 acceptance trigger (manual smoke + Rust unit) + * + * The coverage matrix downgrades 5.2.1 / 5.2.3 to 🟡 to reflect this. + * + * Mac2 skipped — Settings sidebar label mapping not yet exposed to Appium. + */ +function stepLog(message: string, context?: unknown): void { + const stamp = new Date().toISOString(); + if (context === undefined) { + console.log(`[AutocompleteFlowE2E][${stamp}] ${message}`); + return; + } + console.log(`[AutocompleteFlowE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2)); +} + +describe('Autocomplete settings panel smoke', () => { + before(async function beforeSuite() { + if (!supportsExecuteScript()) { + stepLog('Skipping suite on Mac2 — Settings sidebar not mapped'); + this.skip(); + } + + stepLog('starting mock server'); + await startMockServer(); + stepLog('waiting for app'); + await waitForApp(); + stepLog('triggering auth bypass deep link'); + await triggerAuthDeepLinkBypass('e2e-autocomplete-flow'); + await waitForWindowVisible(25_000); + await waitForWebView(15_000); + await waitForAppReady(15_000); + await completeOnboardingIfVisible('[AutocompleteFlowE2E]'); + }); + + after(async () => { + stepLog('stopping mock server'); + await stopMockServer(); + }); + + it('mounts the autocomplete settings panel and renders status', async () => { + stepLog('navigating to /settings/autocomplete'); + await navigateViaHash('/settings/autocomplete'); + + // Panel chrome — at least one of the skill-status labels rendered by + // useAutocompleteSkillStatus must show. Status text is one of: + // Active / Offline / Error / Unsupported. + await waitForText('Auto', 15_000); + const statusVisible = + (await textExists('Active')) || + (await textExists('Offline')) || + (await textExists('Error')) || + (await textExists('Unsupported')); + expect(statusVisible).toBe(true); + }); + + it('renders an Enable / Manage / Retry CTA driven by skill status', async () => { + // Re-establish route state so this case is runnable in isolation; do not + // depend on the previous `it` having navigated to /settings/autocomplete. + stepLog('navigating to /settings/autocomplete (independent setup)'); + await navigateViaHash('/settings/autocomplete'); + await waitForText('Auto', 15_000); + + const ctaVisible = + (await textExists('Enable')) || + (await textExists('Manage')) || + (await textExists('Retry')) || + (await textExists('Details')); + expect(ctaVisible).toBe(true); + }); +}); diff --git a/app/test/e2e/specs/insights-dashboard.spec.ts b/app/test/e2e/specs/insights-dashboard.spec.ts new file mode 100644 index 000000000..7a8dce0ab --- /dev/null +++ b/app/test/e2e/specs/insights-dashboard.spec.ts @@ -0,0 +1,103 @@ +import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; +import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers'; +import { + textExists, + waitForText, + waitForWebView, + waitForWindowVisible, +} from '../helpers/element-helpers'; +import { supportsExecuteScript } from '../helpers/platform'; +import { completeOnboardingIfVisible, navigateViaHash } from '../helpers/shared-flows'; +import { startMockServer, stopMockServer } from '../mock-server'; + +/** + * Insights dashboard smoke spec (features 11.1.3 analyze trigger, + * 11.2.1 memory view, 11.2.2 source filtering, 11.2.3 search). + * + * Goal: prove the /intelligence route mounts, the Memory tab renders, the + * source filter chips are present, and the search input accepts a query + * without throwing. Backend wiring (real memory population) is asserted in + * `memory-roundtrip.spec.ts` — this spec focuses on the dashboard surface. + * + * Mac2 skipped — Intelligence sidebar mapping not yet exposed to Appium + * helpers. + */ +function stepLog(message: string, context?: unknown): void { + const stamp = new Date().toISOString(); + if (context === undefined) { + console.log(`[InsightsDashboardE2E][${stamp}] ${message}`); + return; + } + console.log(`[InsightsDashboardE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2)); +} + +describe('Insights dashboard smoke', () => { + before(async function beforeSuite() { + if (!supportsExecuteScript()) { + stepLog('Skipping suite on Mac2 — Intelligence sidebar not mapped'); + this.skip(); + } + + stepLog('starting mock server'); + await startMockServer(); + stepLog('waiting for app'); + await waitForApp(); + stepLog('triggering auth bypass deep link'); + await triggerAuthDeepLinkBypass('e2e-insights-dashboard'); + await waitForWindowVisible(25_000); + await waitForWebView(15_000); + await waitForAppReady(15_000); + await completeOnboardingIfVisible('[InsightsDashboardE2E]'); + }); + + after(async () => { + stepLog('stopping mock server'); + await stopMockServer(); + }); + + it('mounts the /intelligence route and renders the Memory tab', async () => { + stepLog('navigating to /intelligence'); + await navigateViaHash('/intelligence'); + + // Tabs / page chrome — Memory is the canonical first view. + await waitForText('Memory', 15_000); + expect(await textExists('Memory')).toBe(true); + }); + + it('renders the actionable-items search input (11.2.3) and accepts a query', async () => { + // The Memory tab mounts an `` — assert by id + // so the test cannot false-pass on an unrelated input elsewhere on the page. + // Real keystroke synthesis via the React onChange path is intentional: + // there is no shared helper for typing into arbitrary inputs (only + // clickButton / clickText / clickToggle), and `browser.keys()` is unreliable + // on tauri-driver, so we follow the established pattern from + // `command-palette.spec.ts` (event synthesis via `browser.execute`). + stepLog('typing into #actionable-search'); + const typed = await browser.execute(() => { + const target = document.querySelector('#actionable-search'); + if (!target) return false; + target.focus(); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set; + setter?.call(target, 'roundtrip canary'); + target.dispatchEvent(new Event('input', { bubbles: true })); + return target.value === 'roundtrip canary'; + }); + expect(typed).toBe(true); + }); + + it('renders the actionable-source select (11.2.2) with the All Sources option', async () => { + // 11.2.2 source filtering is a `