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
29 changes: 24 additions & 5 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
1 change: 1 addition & 0 deletions app/src/components/channels/ChannelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/src/pages/Skills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ${
Expand Down
48 changes: 41 additions & 7 deletions app/test/e2e/helpers/chat-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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> {
Expand Down
32 changes: 32 additions & 0 deletions app/test/e2e/helpers/reset-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 =
Expand Down
137 changes: 83 additions & 54 deletions app/test/e2e/helpers/shared-flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,78 +118,110 @@ 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}`;
return withPrefix.replace(/\/$/, '');
}

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;
Comment thread
senamakel-droid marked this conversation as resolved.
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
timeout,
interval: 250,
Expand All @@ -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) =>
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion app/test/e2e/specs/chat-conversation-history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
Expand Down Expand Up @@ -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',
});
Expand Down
3 changes: 2 additions & 1 deletion app/test/e2e/specs/chat-harness-cancel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
*/
import { waitForApp } from '../helpers/app-helpers';
import {
chatMounted,
clickByTitle,
clickSend,
getSelectedThreadId,
Expand Down Expand Up @@ -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',
});
Expand Down
Loading
Loading