mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test(e2e): repair desktop Appium suite after IA refactors (#3649)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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'] },
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')) ||
|
||||
|
||||
Reference in New Issue
Block a user