test(e2e): repair desktop Appium suite after IA refactors (#3649)

This commit is contained in:
Steven Enamakel's Droid
2026-06-13 13:28:39 -07:00
committed by GitHub
parent 595fb41c9b
commit 6ceee181d1
33 changed files with 387 additions and 155 deletions
+24 -5
View File
@@ -7,7 +7,26 @@
name: E2E
on:
workflow_dispatch: {}
workflow_dispatch:
inputs:
run_linux:
description: Run the Linux (Xvfb / container) E2E job.
type: boolean
default: true
run_macos:
description: Run the macOS E2E job.
type: boolean
default: true
run_windows:
description: Run the Windows E2E job.
type: boolean
default: true
full:
description:
When true, run the entire spec suite (sharded). When false, run the
desktop full-flow lane only (mega-flow.spec.ts).
type: boolean
default: false
permissions:
contents: read
@@ -22,7 +41,7 @@ jobs:
e2e-desktop:
uses: ./.github/workflows/e2e-reusable.yml
with:
run_linux: true
run_macos: true
run_windows: true
full: false
run_linux: ${{ inputs.run_linux }}
run_macos: ${{ inputs.run_macos }}
run_windows: ${{ inputs.run_windows }}
full: ${{ inputs.full }}
@@ -72,6 +72,7 @@ const ChannelSelector = ({
<button
key={channelId}
type="button"
data-testid={`channel-select-${channelId}`}
onClick={() => onSelectChannel(channelId)}
className={`flex-1 flex items-center justify-between gap-2 rounded-lg border px-4 py-3 text-sm transition-colors ${
isSelected
+1
View File
@@ -1024,6 +1024,7 @@ export default function Skills() {
<button
key={channelId}
type="button"
data-testid={`channel-select-${channelId}`}
onClick={() => void handleSetDefaultChannel(channelId)}
disabled={defaultChannelBusy !== null}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition-colors ${
+41 -7
View File
@@ -21,17 +21,35 @@
/** Click a button identified by its `title` attribute. Returns `true`
* if a matching button was found and clicked. Polls because the
* composer renders asynchronously after a thread is created. */
* composer renders asynchronously after a thread is created.
*
* Matching is tolerant of trailing keyboard-shortcut hints that the UI
* appends in parentheses: the composer-flattening refactor (#3611) renamed
* the new-thread button's title from `t('chat.newThread')` ("New thread")
* to `t('chat.newThreadShortcut')` ("New thread (/new)"). The button itself
* carries a stable `data-testid="new-thread-button"`, so for that affordance
* we prefer the test id and fall back to exact/prefix title matching for any
* other titled button a spec may target. */
export async function clickByTitle(title: string, timeoutMs = 6_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const clicked = await browser.execute((t: string) => {
const el = document.querySelector(
`button[title=${JSON.stringify(t)}]`
) as HTMLButtonElement | null;
if (!el) return false;
el.click();
return true;
const click = (el: HTMLButtonElement | null) => {
if (!el) return false;
el.click();
return true;
};
// The new-thread button is the canonical `clickByTitle` target and
// exposes a stable test id — prefer it over the i18n title string.
if (t === 'New thread' || t.startsWith('New thread')) {
if (click(document.querySelector('[data-testid="new-thread-button"]'))) return true;
}
// Exact title match, then prefix match (handles " (/new)" style suffixes).
if (click(document.querySelector(`button[title=${JSON.stringify(t)}]`))) return true;
const prefixMatch = Array.from(
document.querySelectorAll<HTMLButtonElement>('button[title]')
).find(b => (b.getAttribute('title') ?? '').startsWith(t));
return click(prefixMatch ?? null);
}, title);
if (clicked) return true;
await browser.pause(200);
@@ -41,6 +59,22 @@ export async function clickByTitle(title: string, timeoutMs = 6_000): Promise<bo
const COMPOSER_SELECTOR = 'textarea[placeholder="How can I help you today?"]';
/** True once the Conversations page has mounted its composer/header.
*
* The composer-flattening refactor (#3611) removed the "Threads" sidebar
* heading that specs previously polled via `textExists('Threads')`, so that
* check could never resolve and every chat spec failed with "Conversations
* did not mount". The chat header's new-thread button and the composer
* textarea are both stable, always-rendered mount signals — poll for either. */
export async function chatMounted(): Promise<boolean> {
return browser.execute(
(composerSel: string) =>
document.querySelector('[data-testid="new-thread-button"]') !== null ||
document.querySelector(composerSel) !== null,
COMPOSER_SELECTOR
);
}
/** Type into the chat composer through WebDriver so React's controlled
* input state and the DOM stay in sync. */
export async function typeIntoComposer(text: string): Promise<void> {
+32
View File
@@ -28,6 +28,18 @@ import { dismissBootCheckGateIfVisible, waitForHomePage, walkOnboarding } from '
interface ResetAppOptions {
/** Skip the auth + onboarding bootstrap. Use for specs that test the welcome/login screens themselves. */
skipAuth?: boolean;
/**
* Also clear the on-disk auth session token (via `auth_clear_session`) so the
* post-reload renderer starts *genuinely* signed out. `test_reset` removes
* active_user.toml + api_key but NOT the auth profile/session token, and
* CoreStateProvider keys "logged in" off that token — so a spec that must
* land on the signed-out Welcome page after a prior login spec needs this.
*
* OPT-IN only: it forces a fully signed-out shell, which would break specs
* that immediately re-authenticate (the loopback/deep-link bypass) and expect
* to stay on /home. Use it with `skipAuth` for pure logged-out flows.
*/
clearAuthSession?: boolean;
/** Override the onboarding-walker log prefix. */
logPrefix?: string;
}
@@ -91,6 +103,26 @@ export async function resetApp(userId: string, options: ResetAppOptions = {}): P
if (setOnboarding.ok) {
stepLog('Restored onboarding_completed=true after reset');
}
// Opt-in: clear the on-disk auth session token so the post-reload renderer
// starts genuinely signed out. `test_reset` removes active_user.toml +
// api_key but NOT the auth profile/session token, and CoreStateProvider
// keys "logged in" off that token — so without this a pure logged-out spec
// (runtime-picker) running after a prior login keeps seeing an
// authenticated snapshot and PublicRoute redirects it to /home. Gated
// behind `clearAuthSession` because specs that re-authenticate right after
// reset must NOT have their freshly-minted session wiped.
if (options.clearAuthSession) {
const cleared = await callOpenhumanRpc('openhuman.auth_clear_session', {}).catch(
(err: unknown) => {
stepLog(`auth_clear_session failed (non-fatal): ${err}`);
return { ok: false as const };
}
);
if (cleared.ok) {
stepLog('Cleared auth session after reset (clearAuthSession)');
}
}
} else {
const errText = String(reset.error ?? '');
const unreachable =
+83 -54
View File
@@ -118,20 +118,45 @@ export async function clickFirstMatch(candidates, timeout = 5_000) {
// Navigation helpers (JS hash-based — icon-only sidebar buttons)
// ---------------------------------------------------------------------------
/** Appium Mac2 cannot run W3C Execute Script in WKWebView — use sidebar labels instead. */
/**
* 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
* "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.
*/
const HASH_TO_SIDEBAR_LABEL = {
// Phase 2/3 IA revamp: /skills → /connections, /intelligence → /activity
'/connections': 'Connections',
'/activity': 'Activity',
'/home': 'Home',
'/chat': 'Assistant',
'/notifications': 'Alerts',
'/chat': 'Chat',
'/human': 'Human',
'/brain': 'Brain',
'/connections': 'Connections',
'/settings': 'Settings',
// Back-compat: old routes redirect — keep entries so existing callers still work
'/skills': 'Connections',
'/intelligence': 'Activity',
};
/**
* Routes that AppRoutes.tsx serves via <Navigate replace>. Navigating to the
* key lands the router on the value, so the hash-settle wait must expect the
* resolved target rather than the requested route. Keep in sync with
* app/src/AppRoutes.tsx.
*/
const HASH_REDIRECTS = {
'/skills': '/connections',
'/channels': '/connections',
'/activity': '/settings/notifications',
'/intelligence': '/settings/notifications',
'/routines': '/settings/automations',
'/workflows': '/settings/automations',
};
/** Resolve a requested hash to where the router actually settles. */
function resolveRedirect(normalized) {
return HASH_REDIRECTS[normalized] || normalized;
}
function normalizeHash(value) {
const raw = String(value || '');
const withPrefix = raw.startsWith('#') ? raw : `#${raw}`;
@@ -139,57 +164,64 @@ function normalizeHash(value) {
}
function routeReadySelector(hash) {
const path = normalizeHash(hash).replace(/^#/, '');
const path = resolveRedirect(normalizeHash(hash).replace(/^#/, ''));
const selectors = {
'/notifications': '[data-testid="integration-notifications-section"]',
'/settings/notifications': '[data-testid="integration-notifications-section"]',
'/settings/cron-jobs': '[data-testid="cron-jobs-panel"]',
'/settings/privacy': '[data-testid="settings-privacy-panel"]',
'/settings/migration': '[data-testid="migration-form"]',
'/settings/voice': '[data-testid="voice-providers-section"]',
'/settings/memory-data': '[data-testid="memory-workspace"]',
// Phase 3: /intelligence → /activity; memory-workspace is dev-gated (tab=memory).
// Use a non-dev-gated selector for the activity route instead.
'/intelligence': '[data-testid="intelligence-tasks"]',
'/activity': '[data-testid="intelligence-tasks"]',
};
return selectors[path] || null;
}
async function routeSignature() {
return browser.execute(() => {
const root = document.getElementById('root');
return (root?.innerText || root?.textContent || '').trim().slice(0, 500);
});
}
async function waitForHashRouteReady(hash, options = {}) {
const { timeout = 10_000, previousSignature = '', allowSameSignature = false } = options;
const expected = normalizeHash(hash);
const { timeout = 10_000 } = options;
// Routes that redirect (e.g. /activity → /settings/notifications) settle on
// the resolved target, so wait for that hash rather than the requested one.
const expected = normalizeHash(`#${resolveRedirect(normalizeHash(hash).replace(/^#/, ''))}`);
const readySelector = routeReadySelector(hash);
// We deliberately do NOT use a root-innerText "signature changed" heuristic:
// the TwoPanelLayout shell keeps a persistent sidebar whose text dominates the
// first 500 chars of root.innerText, so that signature is identical across all
// settings sub-panels and the heuristic never fires. Instead we key off
// readyState + the resolved hash (and a route-ready selector when known),
// tolerating redirects to unmapped targets by accepting a stabilised hash.
let lastHash = null;
let stableCount = 0;
await browser.waitUntil(
async () =>
Boolean(
await browser.execute(
({ target, selector, before, allowSame }) => {
if (document.readyState !== 'complete') return false;
const current = window.location.hash.replace(/\/$/, '');
if (current !== target) return false;
const root = document.getElementById('root');
if (!root) return false;
if (selector && root.querySelector(selector)) return true;
const signature = (root.innerText || root.textContent || '').trim().slice(0, 500);
if (!signature) return false;
return allowSame || signature !== before;
},
{
target: expected,
selector: readySelector,
before: previousSignature,
allowSame: allowSameSignature,
}
)
),
async () => {
const res = await browser.execute(
({ selector }) => {
if (document.readyState !== 'complete') return { loading: true };
const root = document.getElementById('root');
if (!root) return { loading: true };
return {
loading: false,
hasSelector: selector ? root.querySelector(selector) !== null : false,
current: window.location.hash.replace(/\/$/, ''),
};
},
{ selector: readySelector }
);
if (res.loading) return false;
// A known route-ready selector being present is a definitive signal the
// target panel rendered — accept it regardless of the hash, since routes
// can redirect to a different hash (e.g. /settings/memory-data → /brain).
if (res.hasSelector) return true;
// Otherwise accept the resolved target hash, or — for redirects to an
// unmapped target — once the hash has stabilised for ~500ms.
const cur = res.current;
if (cur === expected) return true;
if (cur && cur === lastHash) stableCount += 1;
else {
stableCount = 0;
lastHash = cur;
}
return stableCount >= 2;
},
{
timeout,
interval: 250,
@@ -200,7 +232,10 @@ async function waitForHashRouteReady(hash, options = {}) {
export async function navigateViaHash(hash) {
const normalized = String(hash).replace(/\/$/, '') || hash;
const expectedHash = `#${normalized}`;
// A redirecting route settles on its target hash, so the settle-check must
// expect that target (e.g. requesting /activity lands on /settings/notifications).
const resolved = resolveRedirect(normalized);
const expectedHash = `#${resolved}`;
const hashMatches = currentHash =>
currentHash === expectedHash || String(currentHash).startsWith(`${expectedHash}/`);
const waitForHash = async (timeout = 8_000) =>
@@ -245,16 +280,10 @@ export async function navigateViaHash(hash) {
// Fallback: direct hash set + wait for route readiness.
try {
const beforeSignature = await routeSignature();
const beforeHash = normalizeHash(await browser.execute(() => window.location.hash));
const targetHash = normalizeHash(hash);
await browser.execute(h => {
window.location.hash = h;
}, hash);
await waitForHashRouteReady(hash, {
previousSignature: beforeSignature,
allowSameSignature: beforeHash === targetHash,
});
await waitForHashRouteReady(hash);
const currentHash = await browser.execute(() => window.location.hash);
console.log(`[E2E] Navigated to ${hash} (current: ${currentHash})`);
return;
@@ -21,6 +21,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -83,7 +84,7 @@ describe('Chat conversation history', () => {
it('H1.1 — first message and response rendered', async () => {
console.log(`${LOG_PREFIX} H1.1: navigating to /chat and opening new thread`);
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations panel did not mount',
});
@@ -27,6 +27,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -105,7 +106,7 @@ describe('Chat harness — mid-stream cancel', () => {
it('sends → IN_FLIGHT populates → Cancel clears it before late chunks land', async () => {
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations did not mount',
});
@@ -23,6 +23,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
typeIntoComposer,
@@ -119,7 +120,7 @@ describe('Chat harness — scroll + markdown render', () => {
it('streams long markdown, renders it, auto-anchors to bottom, releases on scroll-up', async function () {
this.timeout(90_000);
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations did not mount',
});
@@ -29,6 +29,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -73,7 +74,7 @@ describe('Chat harness — send + stream', () => {
it('mounts /chat and a new thread is selectable', async () => {
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations did not mount',
});
@@ -22,6 +22,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -144,7 +145,7 @@ describe('Chat harness — orchestrator → subagent continuation flow', () => {
it('orchestrator delegates, researcher asks clarification, user answers, researcher continues, canary lands', async function () {
this.timeout(120_000);
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations did not mount',
});
@@ -37,6 +37,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -108,7 +109,11 @@ describe('Chat harness — orchestrator → subagent flow', () => {
this.timeout(90_000);
await startMockServer();
await waitForApp();
await resetApp(USER_ID);
// clearAuthSession drops any session token a prior chat-harness spec in
// this shard left behind, so the orchestrator/sub-agent run starts from a
// clean signed-in state rather than a polluted one (the source of the
// intermittent "final canary never arrives" failures).
await resetApp(USER_ID, { clearAuthSession: true });
setMockBehavior('llmForcedResponses', JSON.stringify(FORCED_RESPONSES));
// Faster streaming for non-tool-call responses so this spec doesn't
@@ -125,7 +130,7 @@ describe('Chat harness — orchestrator → subagent flow', () => {
it('orchestrator delegates to researcher and produces the final canary', async function () {
this.timeout(90_000);
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations did not mount',
});
@@ -22,6 +22,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -101,7 +102,10 @@ describe('Chat harness — wallet flow', () => {
this.timeout(90_000);
await startMockServer();
await waitForApp();
await resetApp(USER_ID);
// clearAuthSession drops a prior chat-harness spec's leftover session token
// so the crypto sub-agent run starts from a clean signed-in state (a
// polluted session was the source of the intermittent quote-store failures).
await resetApp(USER_ID, { clearAuthSession: true });
});
after(async () => {
@@ -160,7 +164,7 @@ describe('Chat harness — wallet flow', () => {
setMockBehavior('llmStreamChunkDelayMs', '10');
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations did not mount',
});
@@ -16,6 +16,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -112,7 +113,7 @@ describe('Chat multi-tool round', () => {
it('T2.1 — agent calls tool 1 (file_read); timeline shows it', async () => {
console.log(`${LOG_PREFIX} T2.1: navigating to /chat, opening new thread`);
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations panel did not mount',
});
@@ -12,6 +12,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -108,7 +109,7 @@ describe('Chat tool-call lifecycle', () => {
it('T1.1 — tool timeline entry (ToolTimelineBlock) renders during execution', async () => {
console.log(`${LOG_PREFIX} T1.1: navigating to /chat and opening new thread`);
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations panel did not mount',
});
@@ -12,6 +12,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -62,7 +63,7 @@ describe('Chat tool-error recovery', () => {
setMockBehavior('llmStreamScript', ERROR_STREAM_SCRIPT);
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations panel did not mount',
});
+5 -2
View File
@@ -132,11 +132,14 @@ describe('Command palette', () => {
{ timeout: 5000, interval: 200, timeoutMsg: 'cmdk items did not render' }
);
// Labels mirror app/src/lib/commands/globalActions.ts. The IA rename
// retired "Go to Intelligence"/"Go to Skills" in favour of
// "Go to Knowledge & Memory"/"Go to Connections".
const seedLabels = [
'Go Home',
'Go to Chat',
'Go to Intelligence',
'Go to Skills',
'Go to Connections',
'Go to Knowledge & Memory',
'Open Settings',
];
for (const label of seedLabels) {
@@ -1,6 +1,7 @@
// @ts-nocheck
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
typeIntoComposer,
@@ -83,9 +84,9 @@ suiteRunner('Conversations web channel flow', () => {
stepLog('ensure thread exists');
// The agent pipeline requires an active thread. Click "New thread" to
// ensure one is selected (same pattern as chat-harness-send-stream).
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations did not mount (Threads heading missing)',
timeoutMsg: 'Conversations did not mount (composer/new-thread button missing)',
});
expect(await clickByTitle('New thread', 8_000)).toBe(true);
await browser.pause(1_000);
@@ -48,6 +48,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -165,7 +166,7 @@ async function tryWaitForTelegramReply(
async function navigateChatAndSend(prompt: string): Promise<void> {
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations panel did not mount',
});
@@ -675,7 +676,7 @@ describe('Harness — Cross-channel bridge flow', () => {
// Step 1: navigate to web chat and start sending (does NOT await reply yet).
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations panel did not mount',
});
@@ -36,6 +36,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -100,7 +101,7 @@ async function createCronJobOracle(params: {
async function navigateChatAndSend(prompt: string): Promise<string | null> {
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations panel did not mount',
});
@@ -46,6 +46,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -74,7 +75,7 @@ const USER_ID = 'e2e-harness-search-tool-flow';
async function navigateChatAndSend(prompt: string): Promise<void> {
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations panel did not mount',
});
+10 -7
View File
@@ -1,7 +1,6 @@
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
import {
clickText,
textExists,
waitForText,
waitForWebView,
@@ -57,15 +56,19 @@ describe('Insights dashboard smoke', () => {
await stopMockServer();
});
it('mounts the /intelligence route and renders the Memory tab', async () => {
stepLog('navigating to /intelligence');
await navigateViaHash('/intelligence');
it('mounts the intelligence dashboard and renders the Memory tab', async () => {
// The old top-level /intelligence page was folded into Brain as the
// "intelligence" tab, which renders the <Intelligence/> dashboard. That
// dashboard's own sub-tab is selected via ?itab=, so deep-link straight to
// the Memory sub-tab (itab=memory) — clicking the Brain "Memory" sidebar
// group instead would switch Brain away from the intelligence tab.
// See app/src/pages/Brain.tsx and app/src/pages/Intelligence.tsx.
stepLog('navigating to /brain?tab=intelligence&itab=memory');
await navigateViaHash('/brain?tab=intelligence&itab=memory');
// Wait for tab bar to appear then click the Memory tab to activate it.
// The Intelligence dashboard's Memory sub-tab renders the memory workspace.
await waitForText('Memory', 15_000);
expect(await textExists('Memory')).toBe(true);
stepLog('clicking Memory tab');
await clickText('Memory', 10_000);
});
it('renders the memory workspace container (11.2.3)', async () => {
@@ -56,9 +56,10 @@ const PANELS: PanelCheck[] = [
markers: ['Memory', 'Data', 'Storage', 'Export', 'Import', 'Settings'],
},
{
// N2.4 — intelligence / AI settings (top-level route, not nested under /settings)
hash: '/intelligence',
markers: ['Intelligence', 'AI', 'Model', 'Skills', 'Settings'],
// N2.4 — intelligence dashboard moved into Brain (the legacy /intelligence
// and /settings/intelligence routes redirect here).
hash: '/brain?tab=intelligence',
markers: ['Intelligence', 'Memory', 'Subconscious', 'Graph', 'Settings'],
},
{
// N2.5 — developer options
@@ -33,7 +33,8 @@ interface RouteCheck {
}
const ROUTES: RouteCheck[] = [
{ hash: '/chat', markers: ['Threads', 'Chat', 'Message', 'New thread'] },
// Chat composer header: "New" thread button, agent-profile "Reasoning" pill.
{ hash: '/chat', markers: ['New', 'Chat', 'Message', 'Reasoning'] },
// 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'] },
@@ -43,8 +44,10 @@ const ROUTES: RouteCheck[] = [
},
{ hash: '/rewards', markers: ['Rewards', 'Referral', 'Credits', 'Earn', 'Invite'] },
{ hash: '/settings', markers: ['Settings', 'Account', 'Billing', 'Advanced'] },
// Activity page (was /intelligence) — tabs: Tasks, Automations, Subconscious
{ hash: '/activity', markers: ['Tasks', 'Automations', 'Subconscious', 'Activity'] },
// Brain page (the old /activity & /intelligence pages were retired; the
// 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'] },
];
+30 -2
View File
@@ -283,12 +283,27 @@ describe('Onboarding modes — Simple (Cloud) vs Advanced (Custom)', () => {
await pause(400);
await clickOnboardingNext();
// Step 6 — Custom Embeddings (Default). This is the final step → Finish.
// Step 6 — Custom Embeddings (Default).
expect(await testIdExists('onboarding-custom-embeddings-step', 10_000)).toBe(true);
expect(await clickTestId('onboarding-custom-embeddings-step-default')).toBe(true);
await pause(400);
await clickOnboardingNext();
// Step 7 — Custom Activity (Default). Added to CUSTOM_WIZARD_STEPS after
// embeddings (see app/src/pages/onboarding/customWizardSteps.ts).
expect(await testIdExists('onboarding-custom-activity-step', 10_000)).toBe(true);
expect(await clickTestId('onboarding-custom-activity-step-default')).toBe(true);
await pause(400);
await clickOnboardingNext();
// Step 8 — Custom Vault. Final step → Finish. VaultSetupStep auto-selects
// "configure" and hides the choice cards for local sessions (the default
// option is disabled when no cloud account backs the vault), so there is no
// -default button to click — just advance.
expect(await testIdExists('onboarding-custom-vault-step', 10_000)).toBe(true);
await pause(400);
await clickOnboardingNext();
const landed = await waitForHome(20_000);
if (!landed) stepLog(`current hash after custom finish: ${await currentHash()}`);
expect(landed).toBe(true);
@@ -409,12 +424,25 @@ describe('Onboarding modes — Simple (Cloud) vs Advanced (Custom)', () => {
await pause(400);
await clickOnboardingNext();
// Step 6 — Custom Embeddings (Default). Final step → Finish.
// Step 6 — Custom Embeddings (Default).
expect(await testIdExists('onboarding-custom-embeddings-step', 10_000)).toBe(true);
expect(await clickTestId('onboarding-custom-embeddings-step-default')).toBe(true);
await pause(400);
await clickOnboardingNext();
// Step 7 — Custom Activity (Default).
expect(await testIdExists('onboarding-custom-activity-step', 10_000)).toBe(true);
expect(await clickTestId('onboarding-custom-activity-step-default')).toBe(true);
await pause(400);
await clickOnboardingNext();
// Step 8 — Custom Vault. Final step → Finish. VaultSetupStep auto-selects
// "configure" and hides the choice cards for local sessions, so there is no
// -default button to click — just advance.
expect(await testIdExists('onboarding-custom-vault-step', 10_000)).toBe(true);
await pause(400);
await clickOnboardingNext();
expect(await waitForHome(20_000)).toBe(true);
});
});
+2 -2
View File
@@ -68,12 +68,12 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
getSelectedThreadId,
waitForAssistantReplyContaining,
waitForSocketConnected,
} from '../helpers/chat-harness';
import { callOpenhumanRpc } from '../helpers/core-rpc';
import { textExists } from '../helpers/element-helpers';
import { resetApp } from '../helpers/reset-app';
import { navigateViaHash } from '../helpers/shared-flows';
import {
@@ -287,7 +287,7 @@ describe('PTT — global push-to-talk flow', function () {
// create one as needed; this just makes the assertion at step 9
// easier (we can read selectedThreadId and assert message presence).
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations did not mount under /chat',
});
@@ -139,8 +139,11 @@ describe('Runtime picker → login → onboarding → home → logout', () => {
await waitForApp();
resetMockBehavior();
setMockBehavior('composioConnections', '[]');
// skipAuth so we land on Welcome (logged out) — the spec drives login itself.
await resetApp('e2e-runtime-picker-login', { skipAuth: true });
// skipAuth so we land on Welcome (logged out) — the spec drives login
// itself. clearAuthSession wipes the on-disk session token too, so a prior
// login spec in this shard can't leave us authenticated (which would make
// PublicRoute redirect past Welcome to /home).
await resetApp('e2e-runtime-picker-login', { skipAuth: true, clearAuthSession: true });
clearRequestLog();
});
@@ -202,19 +205,35 @@ describe('Runtime picker → login → onboarding → home → logout', () => {
});
it('"Test Connection" against an unreachable host shows the unreachable pill', async function () {
// Polling up to 20s for the connection result + potential accessibility tree dump.
this.timeout(60_000);
// The ModePicker can re-render back to the runtime *choice cards* between
// the sequential `it`s in this suite, dropping the cloud URL/token form the
// previous test revealed. Make this test self-contained: if the Auth Token
// field isn't showing, re-select Cloud and re-enter the (deliberately
// closed-port) URL before filling the token.
if (!(await textExists('Auth Token'))) {
await clickByTextDom('Run on the Cloud (Complex)');
await browser.pause(500);
}
await fillInput('input[type="url"]', 'http://127.0.0.1:1/rpc');
// Token already required; supply something + a deliberately closed port.
const tokenOk = await fillInput('input[type="password"]', 'bad-token-e2e');
// The bearer-token field is a `type="text"` input (password-manager-ignored),
// not `type="password"`, so match it by its placeholder.
const tokenOk = await fillInput(
'input[placeholder="The bearer token from your remote runtime"]',
'bad-token-e2e'
);
expect(tokenOk).toBe(true);
const clicked = await clickByTextDom('Test Connection');
expect(clicked).toBe(true);
// Either "auth failed" (if something happens to respond) or unreachable.
// Both prove the test path actually fired. Poll up to 20s — chromium-driver
// can sit on the connect timeout for a while before failing.
const deadline = Date.now() + 20_000;
// Both prove the test path actually fired. Poll up to 45s — under CI load
// chromium-driver/the core's reqwest client can sit on the TCP connect
// timeout to the deliberately-closed port well past 20s before the
// unreachable pill renders (stays within this.timeout(60_000)).
const deadline = Date.now() + 45_000;
let saw = false;
while (Date.now() < deadline) {
if (
@@ -73,7 +73,8 @@ describe('Settings - Account Preferences', () => {
await navigateViaHash('/settings/privacy');
await waitForText('Privacy', 15_000);
await waitForText('Share Anonymized Usage Data', 15_000);
// Renamed: t('privacy.shareAnonymizedData') = 'Share Product Analytics and Diagnostics'.
await waitForText('Share Product Analytics and Diagnostics', 15_000);
await clickSelector('[data-testid="privacy-analytics-toggle"]');
await clickSelector('[data-testid="privacy-meet-handoff-toggle"]');
@@ -98,23 +99,18 @@ describe('Settings - Account Preferences', () => {
expect(Boolean(snapshot.result?.result?.meetAutoOrchestratorHandoff)).toBe(!initialMeet);
});
it('opens the billing route and settles the redirect status copy', async function () {
it('opens the billing route and shows the moved-to-web redirect panel', async function () {
this.timeout(60_000);
await navigateViaHash('/settings/billing');
await waitForHashContains('/settings/billing');
await waitForText('Open billing dashboard', 15_000);
await browser.waitUntil(
async () =>
// browserNotOpen: shown when open succeeds but browser may not have focused
(await textExists('If your browser did not open, use the button above.')) ||
// browserOpenFailed: shown when openUrl() throws (E2E headless environment)
(await textExists('The browser could not be opened automatically.')) ||
// Opening state (transient)
(await textExists('Opening your browser...')),
{ timeout: 15_000, interval: 500, timeoutMsg: 'billing redirect status did not settle' }
);
// BillingPanel is now a static "moved to the web" redirect page: heading
// t('settings.billing.movedToWeb'), an "Open billing dashboard" button, and
// a "Back to settings" link. The previous auto-open status copy
// ("Opening your browser…" / "If your browser did not open…") was removed,
// so we assert the panel content instead of the transient open state.
await waitForText('Billing moved to the web', 15_000);
expect(await textExists('Open billing dashboard')).toBe(true);
await clickText('Back to settings', 10_000);
await waitForHashContains('/settings');
@@ -39,12 +39,11 @@ describe('Settings - Advanced Config', () => {
this.timeout(90_000);
await navigateViaHash('/settings/developer-options');
await waitForText('Advanced', 15_000);
await waitForText('AI Configuration', 15_000);
// 'Notification Routing' was removed as a top-level dev option.
// 'Composio Routing (Direct Mode)' was renamed to just 'Composio'.
await waitForText('Composio', 15_000);
await waitForText('About', 15_000);
// The dev-mode gate was dropped (#3639) and the per-feature dev entries
// moved to the settings sidebar's "Diagnostics & Logs" group. The
// Developer Options panel is now slim — its stable, panel-specific marker
// is the "Restart Tour" action.
await waitForText('Restart Tour', 15_000);
});
it('persists notification routing settings through core RPC', async function () {
@@ -82,7 +81,9 @@ describe('Settings - Advanced Config', () => {
expect(before.ok).toBe(true);
await navigateViaHash('/settings/composio-triggers');
await waitForText('Integration Triggers', 15_000);
// ComposioTriagePanel renders the triage description + the
// t('composio.disableAllTriage') = 'Disable AI triage for all triggers' toggle.
await waitForText('Disable AI triage for all triggers', 15_000);
const disabledToolkitsInput = await browser.$('#disabled-toolkits');
await disabledToolkitsInput.waitForExist({ timeout: 10_000 });
@@ -114,7 +115,9 @@ describe('Settings - Advanced Config', () => {
const target = current === 250 ? 251 : 250;
await navigateViaHash('/settings/autonomy');
await waitForText('Agent autonomy', 15_000);
// /settings/autonomy redirects to /settings/agent-access, which embeds the
// autonomy rate-limit section titled t('autonomy.maxActionsLabel').
await waitForText('Max actions per hour', 15_000);
const input = await browser.$('#autonomy-max-actions');
await input.waitForExist({ timeout: 10_000 });
@@ -11,7 +11,7 @@
* - 13.2.2 Privacy panel renders + analytics toggle is present
*/
import { waitForApp } from '../helpers/app-helpers';
import { clickText, textExists, waitForText } from '../helpers/element-helpers';
import { clickSelector, textExists, waitForText } from '../helpers/element-helpers';
import { resetApp } from '../helpers/reset-app';
import { navigateViaHash } from '../helpers/shared-flows';
import { startMockServer, stopMockServer } from '../mock-server';
@@ -38,18 +38,38 @@ describe('Settings - Channels & Permissions', () => {
expect(await textExists('Telegram')).toBe(true);
expect(await textExists('Discord')).toBe(true);
await clickText('Discord');
// The active-route line always renders regardless of connection state.
await waitForText('Active route', 5_000);
// Select via the stable channel-select test id rather than the ambiguous
// "Discord" text (which also appears on connection tiles / help copy).
await clickSelector('[data-testid="channel-select-discord"]');
// Confirm the selection persisted to redux state (the Connections messaging
// tab no longer renders the legacy "Active route" line).
await browser.waitUntil(
async () =>
(await browser.execute(() => {
const win = window as unknown as {
__OPENHUMAN_STORE__?: {
getState?: () => { channelConnections?: { defaultMessagingChannel?: string | null } };
};
};
return win.__OPENHUMAN_STORE__?.getState?.().channelConnections?.defaultMessagingChannel;
})) === 'discord',
{
timeout: 10_000,
interval: 500,
timeoutMsg: 'default messaging channel did not switch to discord',
}
);
});
it('renders privacy settings and analytics toggle (13.2.2)', async () => {
await navigateViaHash('/settings/privacy');
await waitForText('Privacy', 15_000);
// PrivacyPanel renders "Anonymized Analytics" section header (not "Data Sharing")
await waitForText('Anonymized Analytics', 15_000);
expect(await textExists('Share Anonymized Usage Data')).toBe(true);
// PrivacyPanel's analytics section was renamed: t('privacy.anonymizedAnalytics')
// is now "Product Analytics" and the toggle label t('privacy.shareAnonymizedData')
// is "Share Product Analytics and Diagnostics".
await waitForText('Product Analytics', 15_000);
expect(await textExists('Share Product Analytics and Diagnostics')).toBe(true);
// Capability list section is "What leaves your computer" (not "Permission Metadata")
await waitForText('What leaves your computer', 5_000);
});
@@ -69,16 +69,16 @@ describe('Settings - Feature Preferences', () => {
await stopMockServer();
});
it('renders the features settings section route', async () => {
it('renders the screen-awareness settings route', async () => {
// The combined "Features" hub was retired: screen-awareness, notifications,
// and tools are now independent sidebar entries. The legacy /settings/features
// slug redirects to /settings/screen-intelligence (see Settings.tsx), which
// renders the Screen Awareness panel.
await navigateViaHash('/settings/features');
await waitForText('Features', 15_000);
// Settings uses t('pages.settings.features.screenAwareness') = 'Screen awareness'
// ScreenIntelligencePanel renders SettingsSection title
// t('settings.features.screenAwareness') = 'Screen awareness'.
await waitForText('Screen awareness', 15_000);
// Phase 2: default messaging channel moved to /connections (Messaging tab);
// the settings/features panel no longer has a "Messaging channels" entry.
await waitForText('Notifications', 15_000);
await waitForText('Tools', 15_000);
});
it('persists the default messaging channel through redux state', async () => {
@@ -87,7 +87,9 @@ describe('Settings - Feature Preferences', () => {
await navigateViaHash('/connections?tab=messaging');
await waitForText('Default Messaging Channel', 15_000);
await clickText('Discord', 10_000);
// Use the stable channel-select test id — "Discord" text also appears on
// connection tiles and help copy, so clickText could hit the wrong node.
await clickSelector('[data-testid="channel-select-discord"]', 10_000);
await browser.waitUntil(async () => (await defaultMessagingChannelFromStore()) === 'discord', {
timeout: 10_000,
interval: 500,
@@ -154,10 +156,15 @@ describe('Settings - Feature Preferences', () => {
expect(await mascotColorChecked('burgundy')).toBe('true');
});
it('persists the custom mascot voice override on the voice panel', async () => {
await navigateViaHash('/settings/voice');
it('persists the custom mascot voice override on the mascot/face panel', async () => {
// The mascot voice override moved into the Personality → Face panel
// (MascotPanel). The legacy /settings/mascot slug redirects to
// /settings/personality#face; /settings/voice now hosts STT/TTS providers.
await navigateViaHash('/settings/mascot');
await waitForText('Mascot Voice', 20_000);
await browser
.$('[data-testid="mascot-voice-select"]')
.waitForExist({ timeout: 20_000, timeoutMsg: 'mascot-voice-select did not render' });
const selectWorked = await setSelectValueByTestId('mascot-voice-select', '__custom__');
if (!selectWorked) {
console.log(
@@ -183,7 +190,15 @@ describe('Settings - Feature Preferences', () => {
interval: 500,
timeoutMsg: 'custom mascot voice did not update',
});
await reloadAndReturnTo('/settings/voice', 'Mascot Voice');
await browser.execute(() => window.location.reload());
await browser.pause(3000);
await navigateViaHash('/settings/mascot');
await browser
.$('[data-testid="mascot-voice-select"]')
.waitForExist({
timeout: 15_000,
timeoutMsg: 'mascot-voice-select did not render after reload',
});
await browser.waitUntil(async () => (await mascotVoiceIdFromStore()) === 'voice-e2e-custom', {
timeout: 15_000,
@@ -21,6 +21,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
@@ -77,7 +78,7 @@ describe('User journey — full research task', () => {
it('J1.1 — message sent and displayed in DOM', async () => {
console.log(`${LOG_PREFIX} J1.1: navigating to /chat`);
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations panel did not mount',
});
@@ -174,7 +175,7 @@ describe('User journey — full research task', () => {
console.log(`${LOG_PREFIX} J1.4: navigating back to /chat`);
await navigateViaHash('/chat');
await browser.waitUntil(async () => await textExists('Threads'), {
await browser.waitUntil(async () => await chatMounted(), {
timeout: 15_000,
timeoutMsg: 'Conversations panel did not remount',
});
@@ -176,7 +176,10 @@ describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => {
});
it('Webhooks page loads (ComposeIO trigger history surface)', async () => {
await navigateViaHash('/settings/webhooks-triggers');
// The webhooks/trigger-history surface was merged into the Integrations
// settings page under the `#webhooks` tab; the legacy /settings/webhooks-triggers
// slug redirects to /settings/integrations#webhooks (see Settings.tsx).
await navigateViaHash('/settings/integrations#webhooks');
await browser.waitUntil(
async () => {
@@ -191,7 +194,7 @@ describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => {
);
const hash = await browser.execute(() => window.location.hash);
expect(String(hash)).toContain('/settings/webhooks-triggers');
expect(String(hash)).toContain('/settings/integrations');
const visible =
(await textExists('ComposeIO Triggers')) ||