Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .github/ci-paths-filter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ frontend:
- 'app/src/**'
- 'app/scripts/**'
- 'app/test/vitest.config.ts'
# The e2e/Playwright specs themselves: `typecheck:e2e` in the frontend
# job exists to check them, so a PR that touches only a spec has to
# match this filter or the job skips and the check reads as green.
- 'app/test/**'
- 'app/tsconfig*.json'
- 'app/vite.config.*'
- 'app/tailwind.config.*'
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/ci-lite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,18 @@ jobs:
env:
NODE_ENV: test

# `compile` above covers `app/src`; its tsconfig does not include the
# test trees, so the 205 WDIO + Playwright specs were never type-checked
# by any lane. `test/tsconfig.e2e.json` was already correct and simply had
# no runner (#6566). Running it found 34 errors, 7 of which were
# `@ts-expect-error` directives that had stopped suppressing anything and
# would have silently swallowed the next real error on the line below them.
- name: Type check E2E and Playwright specs
if: needs.changes.outputs.frontend == 'true'
run: pnpm --filter openhuman-app typecheck:e2e
env:
NODE_ENV: test

- name: Check Prettier formatting
if: needs.changes.outputs.frontend == 'true'
run: pnpm --filter openhuman-app format:check
Expand Down
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"build:web:e2e": "bash ./scripts/e2e-web-build.sh",
"build:web": "cross-env VITE_OPENHUMAN_TARGET=web node scripts/build-parallel.mjs",
"compile": "tsc --noEmit",
"typecheck:e2e": "tsc -p test/tsconfig.e2e.json --noEmit",
"preview": "vite preview",
"tauri": "node scripts/tauri.cjs",
"tauri:build:ui": "node scripts/tauri.cjs build -- --bin OpenHuman",
Expand Down
16 changes: 16 additions & 0 deletions app/scripts/e2e-run-all-flows.sh
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,13 @@ if should_run_suite "chat"; then
run "test/e2e/specs/chat-live-history-parity.spec.ts" "chat-live-history-parity" "chat"
run "test/e2e/specs/chat-tool-error-recovery.spec.ts" "chat-error-recovery" "chat"
run "test/e2e/specs/agent-review.spec.ts" "agent-review" "chat"
run "test/e2e/specs/chat-harness-subagent-continue.spec.ts" "chat-subagent-continue" "chat"
run "test/e2e/specs/chat-background-activity-panel.spec.ts" "chat-background-activity" "chat"
run "test/e2e/specs/chat-todos-goals.spec.ts" "chat-todos-goals" "chat"
run "test/e2e/specs/agent-harness-behaviors.spec.ts" "agent-harness-behaviors" "chat"
run "test/e2e/specs/agent-teams-live.spec.ts" "agent-teams-live" "chat"
run "test/e2e/specs/file-drop-guard.spec.ts" "file-drop-guard" "chat"
run "test/e2e/specs/chat-external-link.spec.ts" "chat-external-link" "chat"
run "test/e2e/specs/mega-flow.spec.ts" "mega-flow" "chat"
_mini_summary "chat"
fi
Expand All @@ -281,6 +288,8 @@ if should_run_suite "skills"; then
run "test/e2e/specs/skill-multi-round.spec.ts" "skill-multi-round" "skills"
run "test/e2e/specs/skill-oauth.spec.ts" "skill-oauth" "skills"
run "test/e2e/specs/skill-socket-reconnect.spec.ts" "skill-socket-reconnect" "skills"
run "test/e2e/specs/skill-activate-invoke-chat.spec.ts" "skill-activate-invoke" "skills"
run "test/e2e/specs/skill-activation-persistence.spec.ts" "skill-activation-persist" "skills"
_mini_summary "skills"
fi

Expand All @@ -293,6 +302,8 @@ if should_run_suite "notifications"; then
run "test/e2e/specs/notifications.spec.ts" "notifications" "notifications"
run "test/e2e/specs/memory-roundtrip.spec.ts" "memory-roundtrip" "notifications"
run "test/e2e/specs/coding-session-memory.spec.ts" "coding-session-memory" "notifications"
run "test/e2e/specs/memory-sources-conversation.spec.ts" "memory-sources-conv" "notifications"
run "test/e2e/specs/memory-sync-schedule.spec.ts" "memory-sync-schedule" "notifications"
run "test/e2e/specs/cron-jobs-flow.spec.ts" "cron-jobs" "notifications"
_mini_summary "notifications"
fi
Expand Down Expand Up @@ -326,6 +337,7 @@ if should_run_suite "providers"; then
run "test/e2e/specs/telegram-channel-flow.spec.ts" "telegram-channel" "providers"
run "test/e2e/specs/gmail-flow.spec.ts" "gmail" "providers"
run "test/e2e/specs/accounts-provider-modal.spec.ts" "accounts-providers" "providers"
run "test/e2e/specs/credential-channels-flow.spec.ts" "credential-channels" "providers"
_mini_summary "providers"
fi

Expand Down Expand Up @@ -372,6 +384,7 @@ if should_run_suite "connectors"; then
run "test/e2e/specs/connector-gmail-composio.spec.ts" "connector-gmail-composio" "connectors"
run "test/e2e/specs/connector-jira.spec.ts" "connector-jira" "connectors"
run "test/e2e/specs/connector-session-guard.spec.ts" "connector-session-guard" "connectors"
run "test/e2e/specs/composio-github-tools-tags.spec.ts" "composio-github-tags" "connectors"
_mini_summary "connectors"
fi

Expand Down Expand Up @@ -399,6 +412,7 @@ if should_run_suite "settings"; then
run "test/e2e/specs/settings-account-preferences.spec.ts" "settings-account" "settings"
run "test/e2e/specs/settings-advanced-config.spec.ts" "settings-advanced" "settings"
run "test/e2e/specs/settings-feature-preferences.spec.ts" "settings-features" "settings"
run "test/e2e/specs/settings-search.spec.ts" "settings-search" "settings"
_mini_summary "settings"
fi

Expand All @@ -418,6 +432,7 @@ if should_run_suite "system"; then
# PR #1061 (core is now in-process). Skip by not setting OPENHUMAN_SERVICE_MOCK=1.
run "test/e2e/specs/service-connectivity-flow.spec.ts" "service-connectivity" "system"
run "test/e2e/specs/core-port-conflict-recovery.spec.ts" "core-port-conflict" "system"
run "test/e2e/specs/ptt-flow.spec.ts" "ptt-flow" "system"
if [[ "$(uname -s)" == "Linux" ]]; then
run "test/e2e/specs/linux-cef-deb-runtime.spec.ts" "linux-cef-deb-runtime" "system"
fi
Expand All @@ -433,6 +448,7 @@ if should_run_suite "journeys"; then
run "test/e2e/specs/user-journey-full-task.spec.ts" "journey-full-task" "journeys"
run "test/e2e/specs/user-journey-settings-round-trip.spec.ts" "journey-settings" "journeys"
run "test/e2e/specs/chat-conversation-history.spec.ts" "chat-history" "journeys"
run "test/e2e/specs/flows.spec.ts" "flows" "journeys"
_mini_summary "journeys"
fi

Expand Down
66 changes: 66 additions & 0 deletions app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { prepareOAuthLoginLaunch } from '../../../utils/oauthAppVersionGate';
import { openUrl } from '../../../utils/openUrl';
import { isTauri } from '../../../utils/tauriCommands';
import OAuthProviderButton from '../OAuthProviderButton';
import { oauthProviderConfigs } from '../providerConfigs';

vi.mock('../../../services/backendHealth', () => ({ checkBackendHealthy: vi.fn() }));

Expand Down Expand Up @@ -518,3 +519,68 @@ describe('OAuthProviderButton web dev redirect', () => {
expect(target.searchParams.get('redirectUri')).toBeNull();
});
});

// Every case above this point renders `stubProvider` (google) or a google stub
// with the id swapped, so all of them pass even if `github` or `discord` were
// misspelled in the real config. The provider id is the ONLY part of a login
// that is observable client-side: `/auth/me` returns no provider field and the
// session crate models none, so a wrong id here is invisible until the backend
// 404s. Drive the real config, not a stub. (matrix 1.1.1-1.1.4)
// [provider id, accessible button name]. Written out rather than derived, so
// the table below is a claim about what SHOULD ship, not an echo of what does.
const EXPECTED_LOGIN_PROVIDERS = [
['google', 'Google'],
['github', 'GitHub'],
['twitter', 'Twitter'],
['discord', 'Discord'],
] as const;

describe('OAuthProviderButton — every configured provider reaches its own backend route', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(checkBackendHealthy).mockResolvedValue(healthyResult);
vi.mocked(openUrl).mockResolvedValue(undefined);
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(getDeepLinkAuthState).mockReturnValue({
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
});

afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});

// Pins the fixture itself. Without this, the `it.each` below degrades to a
// no-op the day someone empties the config, and the suite stays green while
// login is broken for a provider nobody tested by hand.
it('the shipped config is exactly the four expected providers', () => {
expect(oauthProviderConfigs.map(config => config.id)).toEqual(
EXPECTED_LOGIN_PROVIDERS.map(([id]) => id)
);
});

it.each(EXPECTED_LOGIN_PROVIDERS)('provider %s opens /auth/%s/login', async (id, name) => {
// Looked up by the expected id rather than iterated off the config: a
// table built by mapping the config would assert `/auth/<whatever the
// config says>/login` and pass for a typo'd id. This fails instead.
const config = oauthProviderConfigs.find(candidate => candidate.id === id);
expect(config, `no provider config with id "${id}"`).toBeDefined();

render(<OAuthProviderButton provider={config!} />);

fireEvent.click(screen.getByRole('button', { name }));
await act(async () => {
for (let i = 0; i < 6; i++) await Promise.resolve();
});

expect(openUrl).toHaveBeenCalledTimes(1);
const opened = new URL(vi.mocked(openUrl).mock.calls[0][0] as string);
// Exact pathname, not a `contains`: `/auth/x/login` must not satisfy a
// check for `/auth/twitter/login`, and vice versa.
expect(opened.origin + opened.pathname).toBe(`https://backend.test/auth/${id}/login`);
});
});
64 changes: 64 additions & 0 deletions app/src/components/settings/panels/ToolsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,67 @@ describe('<ToolsPanel />', () => {
expect(screen.getByRole('button', { name: 'Save Changes' })).toBeInTheDocument();
});
});

/**
* `setOnboardingTasks` takes the WHOLE `StoredOnboardingTasks` record, so this
* panel has to re-send every flag it does not own. It does that with
* `onboardingTasks?.<flag> ?? false` (ToolsPanel.tsx, `handleSave`). Every
* fixture in the suite above sets all of those flags to `false`/`[]`, so a
* regression that replaced the read-through with a literal `false` would be
* invisible there. (matrix 2.2.3)
*
* Context worth knowing before reading these as "permissions are covered":
* `accessibilityPermissionGranted` is, at this commit, never written `true` by
* anything in `app/src` — both writers (this panel and
* `pages/onboarding/OnboardingLayout.tsx`) read it and write it straight back.
* Nothing re-derives it from the core's `detect_permissions()`. These tests do
* not fix that; they make sure that when it is fixed, saving an unrelated
* settings panel does not silently wipe it again.
*/
describe('<ToolsPanel /> — saving tools preserves the onboarding flags it does not own', () => {
const populatedCoreState = {
snapshot: {
localState: {
onboardingTasks: {
accessibilityPermissionGranted: true,
localModelConsentGiven: true,
localModelDownloadStarted: true,
enabledTools: ['shell'],
connectedSources: ['gmail'],
},
},
},
setOnboardingTasks: mocks.setOnboardingTasks,
};

beforeEach(() => {
vi.clearAllMocks();
mocks.useCoreStateMock.mockReturnValue(populatedCoreState);
mocks.setOnboardingTasks.mockResolvedValue(undefined);
});

it('round-trips accessibilityPermissionGranted, model-consent and sources unchanged', async () => {
render(<ToolsPanel />);

const shellToggle = screen.getByRole('switch', { name: /Shell Commands/ });
await waitFor(() => expect(shellToggle).toHaveAttribute('aria-checked', 'true'));

fireEvent.click(shellToggle);
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));

await waitFor(() => expect(mocks.setOnboardingTasks).toHaveBeenCalledTimes(1));
const saved = mocks.setOnboardingTasks.mock.calls[0][0];

// The flags this panel does not own must survive its save untouched. A
// hardcoded `false` here silently revokes a recorded macOS permission and
// a recorded local-model consent every time someone edits tool settings.
expect(saved.accessibilityPermissionGranted).toBe(true);
expect(saved.localModelConsentGiven).toBe(true);
expect(saved.localModelDownloadStarted).toBe(true);
expect(saved.connectedSources).toEqual(['gmail']);

// And the thing it does own still changed, so the assertions above are not
// passing because the save never happened.
expect(saved.enabledTools).toEqual([]);
});
});
5 changes: 5 additions & 0 deletions app/src/features/conversations/aui/ChatMemoryChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ function createMemoryToolCall(toolName: string): ToolCallMessagePartComponent {
if (chips.length === 0) return null;
return (
<MemoryChips
// The vendored element carries only `data-slot="memory-chips"`, which
// every instance shares. `toolName` makes the hook name which memory
// tool produced these chips, so a spec can tell a `memory_store` write
// from a `memory_recall` read in a turn that did both.
data-testid={`chat-memory-chips-${toolName}`}
chips={chips}
headingRememberedLabel={n =>
t('conversations.memoryChips.remembered').replace('{n}', String(n))
Expand Down
33 changes: 33 additions & 0 deletions app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,37 @@ describe('OnboardingLayout — Joyride walkthrough integration (#1123)', () => {
expect.objectContaining({ enabledTools: existing })
);
});

/**
* Same read-through guard as `ToolsPanel.test.tsx`, for the other writer.
*
* `completeAndExit` re-sends the whole `StoredOnboardingTasks` record, so it
* has to carry `accessibilityPermissionGranted` through with
* `?? false` (OnboardingLayout.tsx). Every other fixture in this file passes
* `false` for it, so a regression that hardcoded `false` would be invisible
* here. (matrix 2.2.3)
*
* Worth knowing: at this commit nothing in `app/src` ever writes that flag
* `true` — both writers read it and write it straight back, and nothing
* re-derives it from the core's `detect_permissions()`. This test does not
* fix that; it makes sure the value is not dropped once it can be set.
*/
it('carries a recorded accessibility permission through onboarding completion', async () => {
const { mockSetOnboardingTasks } = await setupLayout({
accessibilityPermissionGranted: true,
localModelConsentGiven: false,
localModelDownloadStarted: false,
enabledTools: ['shell'],
connectedSources: [],
updatedAtMs: 1,
});

await act(async () => {
fireEvent.click(screen.getByTestId('complete-btn'));
});

expect(mockSetOnboardingTasks).toHaveBeenCalledWith(
expect.objectContaining({ accessibilityPermissionGranted: true })
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';

import reducer, { beginInferenceTurn, markInferenceTurnStreaming } from '../chatRuntimeSlice';

/**
* The Playwright driver dispatches `beginInferenceTurn` as a RAW ACTION OBJECT.
*
* `app/test/playwright/helpers/chat-drive.ts` (`armTurnLifecycle`) cannot import
* the slice's action creator — it runs inside the page, through
* `page.evaluate`, with only `window.__OPENHUMAN_STORE__` to work with. So it
* writes the action type as the literal string `'chatRuntime/beginInferenceTurn'`.
*
* Redux silently ignores an action whose type matches no reducer. If the slice
* were renamed, or this reducer renamed, that literal would become a no-op and
* every Playwright surface gated on `s.thread.isRunning` would quietly stop
* being observable again — which is the exact failure the driver change fixed,
* so it would look like a regression in the product rather than in the driver.
*
* These tests are the pin. They live with the slice, not with the spec, because
* the thing that can break is a rename here.
*/
describe('chatRuntimeSlice — turn lifecycle wire contract (Playwright driver)', () => {
/** Kept byte-identical to the literal in `chat-drive.ts`'s `armTurnLifecycle`. */
const DRIVER_ACTION_TYPE = 'chatRuntime/beginInferenceTurn';

it('beginInferenceTurn keeps the action type the Playwright driver hardcodes', () => {
expect(beginInferenceTurn.type).toBe(DRIVER_ACTION_TYPE);
});

it('a raw action object with that type creates the lifecycle entry', () => {
// Deliberately NOT the action creator: the creator working proves nothing
// about the string the driver actually sends.
const next = reducer(undefined, {
type: DRIVER_ACTION_TYPE,
payload: { threadId: 'thread-1' },
});

expect(next.inferenceTurnLifecycleByThread['thread-1']).toBe('started');
});

it('markInferenceTurnStreaming alone cannot arm a thread, which is why the driver must', () => {
// The reason `armTurnLifecycle` exists. `ChatRuntimeProvider` dispatches
// this on the socket's `inference_start`; against a thread with no entry it
// is a no-op, so an RPC-driven turn would never become `isRunning`.
const withoutBegin = reducer(undefined, markInferenceTurnStreaming({ threadId: 'thread-1' }));
expect(withoutBegin.inferenceTurnLifecycleByThread['thread-1']).toBeUndefined();

const armed = reducer(undefined, beginInferenceTurn({ threadId: 'thread-1' }));
const streaming = reducer(armed, markInferenceTurnStreaming({ threadId: 'thread-1' }));
expect(streaming.inferenceTurnLifecycleByThread['thread-1']).toBe('streaming');
});
});
3 changes: 0 additions & 3 deletions app/test/e2e/helpers/composio-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ export async function openConnectorModal(
const statusDeadline = Date.now() + timeout;
while (Date.now() < statusDeadline) {
if (await textExists(waitForTileStatus)) break;
// @ts-expect-error -- browser global is injected by WDIO at runtime, not typed in this env
await browser.pause(300);
}
} catch {
Expand All @@ -138,7 +137,6 @@ export async function openConnectorModal(
await ensureModalOpen();
lastReopenAt = Date.now();
}
// @ts-expect-error -- browser global is injected by WDIO at runtime, not typed in this env
await browser.pause(250);
}

Expand Down Expand Up @@ -179,7 +177,6 @@ export async function assertModalPhase(
return;
}
}
// @ts-expect-error -- browser global is injected by WDIO at runtime, not typed in this env
await browser.pause(400);
}

Expand Down
Loading
Loading