Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
db6092d
test(ci): exercise full validation suite
senamakel Jul 21, 2026
e3ee735
test(e2e): open notifications main view for section checks
senamakel Jul 21, 2026
d9a3f39
test(ci): repair full-suite regressions
senamakel Jul 21, 2026
26c842c
test(e2e): harden cold-start and composer flows
senamakel Jul 21, 2026
69affac
Merge remote-tracking branch 'upstream/main' into test/full-suite-202…
senamakel Jul 21, 2026
da075ad
docs(test): limit matrix update to affected brain features
senamakel Jul 21, 2026
c953177
test(composio): exercise per-action contract gate
senamakel Jul 21, 2026
22fdc95
test(e2e): probe the full core fallback port range
senamakel Jul 21, 2026
e439b6a
test(playwright): realign full web suite
senamakel Jul 21, 2026
85f985b
Merge remote-tracking branch 'upstream/main' into test/full-suite-202…
senamakel Jul 21, 2026
5b88b95
test(playwright): eliminate residual web lane flakes
senamakel Jul 21, 2026
04edf3a
test(playwright): await persisted preferences
senamakel Jul 21, 2026
66f0476
Merge remote-tracking branch 'upstream/main' into test/full-suite-202…
senamakel Jul 21, 2026
935f7da
test(agent): update full-suite prompt fixtures
senamakel Jul 21, 2026
c10c0df
test(agent-team): await member cleanup after completion
senamakel Jul 21, 2026
5302d39
Merge remote-tracking branch 'upstream/main' into test/full-suite-202…
senamakel Jul 21, 2026
25b97c8
ci(e2e): split payments and settings shards
senamakel Jul 21, 2026
a447c83
Merge remote-tracking branch 'upstream/main' into test/full-suite-202…
senamakel Jul 21, 2026
4c546b7
test(tokenjuice): keep workflow fixture above compaction threshold
senamakel Jul 21, 2026
4a7bc97
test(e2e): rediscover core after reset
senamakel Jul 21, 2026
f445d13
test(e2e): bound stale core discovery
senamakel Jul 21, 2026
6528df8
test(e2e): retain observed final answer
senamakel Jul 21, 2026
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
12 changes: 10 additions & 2 deletions .github/workflows/ci-full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -287,13 +287,21 @@ jobs:
mkdir -p "$OPENHUMAN_WORKSPACE"
bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-web-session.sh

- name: Pack Playwright E2E failure artifacts
if: failure()
run: |
mkdir -p .ci/artifacts
tar -czf .ci/artifacts/openhuman-playwright-failure-logs.tar.gz \
-C "$OPENHUMAN_WORKSPACE" .
env:
OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace

- name: Upload Playwright E2E failure artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: e2e-playwright-failure-logs-${{ github.run_id }}
path: |
${{ runner.temp }}/openhuman-playwright-workspace/**
path: .ci/artifacts/openhuman-playwright-failure-logs.tar.gz
retention-days: 7
if-no-files-found: ignore

Expand Down
17 changes: 14 additions & 3 deletions .github/workflows/e2e-playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ jobs:
runs-on: ubuntu-22.04
container:
image: ghcr.io/tinyhumansai/openhuman_ci:latest
timeout-minutes: 30
# The complete serial web suite currently takes about 45 minutes on the
# shared runner; keep the standalone diagnostic workflow aligned with the
# 90-minute budget used by CI Full.
timeout-minutes: 90
steps:
- name: Checkout code
uses: actions/checkout@v7
Expand Down Expand Up @@ -73,12 +76,20 @@ jobs:
mkdir -p "$OPENHUMAN_WORKSPACE"
bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-web-session.sh

- name: Pack Playwright E2E failure artifacts
if: failure()
run: |
mkdir -p .ci/artifacts
tar -czf .ci/artifacts/openhuman-playwright-failure-logs.tar.gz \
-C "$OPENHUMAN_WORKSPACE" .
env:
OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace

- name: Upload Playwright E2E failure artifacts
if: failure()
uses: actions/upload-artifact@v7
with:
name: e2e-playwright-failure-logs-${{ github.run_id }}
path: |
${{ runner.temp }}/openhuman-playwright-workspace/**
path: .ci/artifacts/openhuman-playwright-failure-logs.tar.gz
retention-days: 7
if-no-files-found: ignore
9 changes: 6 additions & 3 deletions .github/workflows/e2e-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,8 @@ jobs:
- { name: provider-web, suites: "provider-web" }
- { name: webhooks, suites: "webhooks" }
- { name: connectors, suites: "connectors" }
- { name: commerce, suites: "payments,settings" }
- { name: payments, suites: "payments" }
- { name: settings, suites: "settings" }
steps:
- name: Checkout code
uses: actions/checkout@v7
Expand Down Expand Up @@ -765,7 +766,8 @@ jobs:
- { name: provider-web, suites: "provider-web" }
- { name: webhooks, suites: "webhooks" }
- { name: connectors, suites: "connectors" }
- { name: commerce, suites: "payments,settings" }
- { name: payments, suites: "payments" }
- { name: settings, suites: "settings" }
steps:
- name: Checkout code
uses: actions/checkout@v7
Expand Down Expand Up @@ -976,7 +978,8 @@ jobs:
- { name: provider-web, suites: "provider-web" }
- { name: webhooks, suites: "webhooks" }
- { name: connectors, suites: "connectors" }
- { name: commerce, suites: "payments,settings" }
- { name: payments, suites: "payments" }
- { name: settings, suites: "settings" }
steps:
- name: Checkout code
uses: actions/checkout@v7
Expand Down
6 changes: 4 additions & 2 deletions app/scripts/e2e-run-shards.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
# chat = chat, skills, journeys
# integrations = providers, webhooks, notifications
# connectors = connectors
# commerce = payments, settings
# payments = payments
# settings = settings
#
set -uo pipefail

Expand All @@ -32,7 +33,8 @@ SHARDS=(
"providers:providers,notifications"
"webhooks:webhooks"
"connectors:connectors"
"commerce:payments,settings"
"payments:payments"
"settings:settings"
)

# Allow filtering: `bash e2e-run-shards.sh foundation chat`
Expand Down
53 changes: 52 additions & 1 deletion app/test/core-rpc-node.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { formatRpcCallFailure } from './e2e/helpers/core-rpc-node';

Expand All @@ -15,3 +15,54 @@ describe('formatRpcCallFailure', () => {
);
});
});

describe('callOpenhumanRpcNode', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});

it('rediscovers the core when the cached listener disappears after a reset', async () => {
const requestedUrls: string[] = [];
let firstListenerAlive = true;

vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
requestedUrls.push(url);
const body = JSON.parse(String(init?.body)) as { method: string };

if (url.includes(':7788/')) {
if (body.method === 'core.ping' && firstListenerAlive) {
return new Response('', { status: 401 });
}
if (body.method === 'openhuman.first_call' && firstListenerAlive) {
return Response.json({ result: 'first' });
}
throw new TypeError('fetch failed');
}

if (url.includes(':7789/')) {
if (body.method === 'core.ping') return new Response('', { status: 401 });
return Response.json({ result: 'replacement' });
}

throw new TypeError('fetch failed');
})
);

const { callOpenhumanRpcNode } = await import('./e2e/helpers/core-rpc-node');
await expect(callOpenhumanRpcNode('openhuman.first_call')).resolves.toMatchObject({
ok: true,
result: 'first',
});

firstListenerAlive = false;
await expect(callOpenhumanRpcNode('openhuman.after_reset')).resolves.toMatchObject({
ok: true,
result: 'replacement',
});
expect(requestedUrls.some(url => url.includes(':7789/rpc'))).toBe(true);
});
});
62 changes: 30 additions & 32 deletions app/test/e2e/helpers/chat-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,42 +78,40 @@ export async function chatMounted(): Promise<boolean> {
/** 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> {
const composer = await browser.$(COMPOSER_SELECTOR);
await composer.waitForDisplayed({ timeout: 10_000 });
await composer.waitForEnabled({ timeout: 10_000 });
let actual = '';
for (let attempt = 1; attempt <= 3; attempt += 1) {
// Creating a thread can replace the controlled textarea after the selected
// thread id changes. Resolve it afresh on every attempt so a late React
// commit cannot leave WebDriver typing into a detached element.
const composer = await browser.$(COMPOSER_SELECTOR);
await composer.waitForDisplayed({ timeout: 10_000 });
await composer.waitForEnabled({ timeout: 10_000 });

// Step 1: Focus via JS — avoids the coordinate-based click that gets
// intercepted by AppUpdatePrompt (z-[9998], fixed bottom-4 right-4).
// We also select-all any existing text so the subsequent delete clears it.
const focused = await browser.execute((sel: string) => {
const el = document.querySelector(sel) as HTMLTextAreaElement | null;
if (!el) return false;
el.focus();
el.select();
return true;
}, COMPOSER_SELECTOR);
if (!focused) {
throw new Error('typeIntoComposer: textarea not found');
}
// Focus via JS — avoids the coordinate-based click that gets intercepted
// by AppUpdatePrompt. Select any partial value before deleting it.
const focused = await browser.execute((sel: string) => {
const el = document.querySelector(sel) as HTMLTextAreaElement | null;
if (!el) return false;
el.focus();
el.select();
return true;
}, COMPOSER_SELECTOR);
if (!focused) continue;

// Step 2: Clear existing content. el.select() inside browser.execute already
// selected all text; browser.keys('Delete') now removes the selection so
// React's controlled state sees an empty value before we start typing.
await browser.pause(80);
await browser.keys('Delete');
await browser.pause(80);
await browser.pause(80);
await browser.keys('Delete');
await browser.pause(80);

// Step 3: Type the text using real OS-level keyboard events (browser.keys).
// Unlike synthetic DOM events dispatched via browser.execute(), these go
// through Chromium's normal input pipeline, triggering React's onChange
// on the controlled textarea and correctly updating `inputValue` state so
// the send button becomes enabled.
await browser.keys(text.split(''));
// Real keyboard events keep React's controlled state and the DOM in sync.
await browser.keys(text.split(''));
await browser.pause(200);
actual = String(await composer.getValue());
if (actual === text) return;
}

await browser.waitUntil(async () => (await composer.getValue()) === text, {
timeout: 5_000,
timeoutMsg: 'chat composer did not receive typed text',
});
throw new Error(
`chat composer did not receive typed text after 3 attempts (actual length ${actual.length}, expected ${text.length})`
);
}

/** Click the chat composer's send button. Returns `false` if the
Expand Down
80 changes: 51 additions & 29 deletions app/test/e2e/helpers/core-rpc-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@ function coreHost(): string {
return (process.env.OPENHUMAN_CORE_HOST || '127.0.0.1').trim() || '127.0.0.1';
}

/** Ports to try when OPENHUMAN_CORE_PORT is unset (matches typical dev sidecar range). */
/** Ports to try when OPENHUMAN_CORE_PORT is unset.
*
* Keep this exactly aligned with connectivity::rpc's desktop fallback range.
* A data reset restarts the embedded core; on Windows the preferred socket can
* remain unavailable briefly, so the replacement listener may bind as high as
* 7798. Stopping at 7793 makes every later RPC test wait out the full probe
* deadline even though the restarted core is healthy. */
function defaultPortProbeList(): number[] {
const raw = process.env.OPENHUMAN_CORE_PORT?.trim();
if (raw) {
Expand All @@ -100,7 +106,7 @@ function defaultPortProbeList(): number[] {
}
}
const ports: number[] = [];
for (let port = 7788; port <= 7793; port += 1) ports.push(port);
for (let port = 7788; port <= 7798; port += 1) ports.push(port);
return ports;
}

Expand All @@ -113,6 +119,9 @@ async function tryPingRpc(url: string): Promise<boolean> {
method: 'POST',
headers: buildHeaders(false),
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }),
// A recently stopped listener can take several seconds to reject on
// Windows. Keep discovery inside resetApp's eight-second RPC budget.
signal: AbortSignal.timeout(750),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep RPC discovery within reset budget

When the cached listener is stale and the restarted embedded core binds near the end of the newly-expanded fallback range (for example, 7798), discovery first pings the cached URL and then probes ports sequentially; with this 750 ms timeout, those probes can exceed resetApp's 8-second Promise.race before the live port is reached. In that case resetApp treats a healthy restarted core as “sidecar likely not started” and skips the wipe/re-auth baseline, leaving later desktop E2E shards with stale state. Consider increasing that reset budget or making discovery parallel/shorter per port.

Useful? React with 👍 / 👎.

});
// 401 means "endpoint exists, auth required" — that's a positive match
// for the core RPC URL; the real call will retry with auth attached.
Expand All @@ -130,7 +139,10 @@ async function tryPingRpc(url: string): Promise<boolean> {
* `OPENHUMAN_CORE_HOST` + `OPENHUMAN_CORE_PORT`, then probe host:port until core.ping succeeds.
*/
export async function resolveCoreRpcUrl(): Promise<string> {
if (cachedRpcUrl) return cachedRpcUrl;
if (cachedRpcUrl) {
if (await tryPingRpc(cachedRpcUrl)) return cachedRpcUrl;
cachedRpcUrl = null;
}

const env = process.env.OPENHUMAN_CORE_RPC_URL?.trim();
if (env) {
Expand Down Expand Up @@ -162,33 +174,43 @@ export async function callOpenhumanRpcNode<T = unknown>(
method: string,
params: Record<string, unknown> = {}
): Promise<RpcCallResult<T>> {
try {
const rpcUrl = await resolveCoreRpcUrl();
const id = Math.floor(Math.random() * 1e9);
const res = await fetch(rpcUrl, {
method: 'POST',
headers: buildHeaders(),
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
});
const text = await res.text();
let json: { error?: { message?: string }; result?: T };
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
json = JSON.parse(text) as typeof json;
} catch {
return {
ok: false,
httpStatus: res.status,
error: `Invalid JSON (${res.status}): ${text.slice(0, 240)}`,
};
}
if (!res.ok) {
return { ok: false, httpStatus: res.status, error: text.slice(0, 500) };
}
if (json.error) {
return { ok: false, error: json.error.message || JSON.stringify(json.error) };
const rpcUrl = await resolveCoreRpcUrl();
const id = Math.floor(Math.random() * 1e9);
const res = await fetch(rpcUrl, {
method: 'POST',
headers: buildHeaders(),
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
});
const text = await res.text();
let json: { error?: { message?: string }; result?: T };
try {
json = JSON.parse(text) as typeof json;
} catch {
return {
ok: false,
httpStatus: res.status,
error: `Invalid JSON (${res.status}): ${text.slice(0, 240)}`,
};
}
if (!res.ok) {
return { ok: false, httpStatus: res.status, error: text.slice(0, 500) };
}
if (json.error) {
return { ok: false, error: json.error.message || JSON.stringify(json.error) };
}
return { ok: true, result: json.result };
} catch (e) {
// A data reset can restart the embedded core on another fallback port.
// Discard a cached listener after a transport failure and discover the
// replacement once before surfacing the error to the spec.
cachedRpcUrl = null;
if (attempt === 1) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}
return { ok: true, result: json.result };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}

return { ok: false, error: 'Core JSON-RPC retry exhausted' };
}
4 changes: 3 additions & 1 deletion app/test/e2e/specs/chat-multi-tool-round.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ describe('Chat multi-tool round', () => {

// Watch for file_read to appear in the timeline.
let sawFileRead = false;
let sawFinal = false;
const deadline = Date.now() + 45_000;
while (Date.now() < deadline) {
const snap = await getToolTimeline(threadId);
Expand All @@ -149,14 +150,15 @@ describe('Chat multi-tool round', () => {
break;
}
if (await textExists(CANARY_FINAL)) {
sawFinal = true;
console.log(`${LOG_PREFIX} T2.1: final answer arrived (tools may have already cycled)`);
break;
}
await browser.pause(200);
}

const finalArrived = await textExists(CANARY_FINAL);
expect(sawFileRead || finalArrived).toBe(true);
expect(sawFileRead || sawFinal || finalArrived).toBe(true);
console.log(`${LOG_PREFIX} T2.1: passed`);
});

Expand Down
Loading
Loading