test(e2e): stabilize chat release-lane tail (#4527)

This commit is contained in:
Steven Enamakel
2026-07-04 17:23:17 -07:00
committed by GitHub
parent 8057ca3090
commit 7fd935e7a9
5 changed files with 70 additions and 22 deletions
+1
View File
@@ -2099,6 +2099,7 @@ const Conversations = ({
}>
<div
ref={messagesContainerRef}
data-testid="chat-messages-scroll"
// Full-width scroll (scrollbar hugs the window edge); inner content is
// centered and width-capped per branch below. `min-h-0` lets this
// basis-0 flex child shrink to 0 so the composer footer can take the
@@ -64,34 +64,27 @@ const STREAM_SCRIPT = [
{ finish: 'stop' },
];
// The message column is the `<div ref={messagesContainerRef}
// className="flex-1 min-h-0 overflow-y-auto ...">` in Conversations.tsx.
// There's only one in /chat so the structural class predicate is enough.
async function scrollMetrics(): Promise<{
scrollTop: number;
scrollHeight: number;
clientHeight: number;
found: boolean;
}> {
return (await browser.execute(() => {
// The messages scroll container is `flex-1 min-h-0 overflow-y-auto`
// (Conversations.tsx). The former hardcoded `bg-[#f6f6f6]` class was
// replaced by a surface token, so match on the structural classes instead.
const el = document.querySelector('div.flex-1.min-h-0.overflow-y-auto') as HTMLElement | null;
if (!el) return { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
const el = document.querySelector('[data-testid="chat-messages-scroll"]') as HTMLElement | null;
if (!el) return { scrollTop: 0, scrollHeight: 0, clientHeight: 0, found: false };
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
found: true,
};
})) as { scrollTop: number; scrollHeight: number; clientHeight: number };
})) as { scrollTop: number; scrollHeight: number; clientHeight: number; found: boolean };
}
async function scrollMessageColumn(top: number): Promise<void> {
await browser.execute((y: number) => {
// The messages scroll container is `flex-1 min-h-0 overflow-y-auto`
// (Conversations.tsx). The former hardcoded `bg-[#f6f6f6]` class was
// replaced by a surface token, so match on the structural classes instead.
const el = document.querySelector('div.flex-1.min-h-0.overflow-y-auto') as HTMLElement | null;
const el = document.querySelector('[data-testid="chat-messages-scroll"]') as HTMLElement | null;
if (el) el.scrollTo({ top: y, behavior: 'auto' });
}, top);
}
@@ -181,7 +174,18 @@ describe('Chat harness — scroll + markdown render', () => {
// ── 3. Auto-scroll anchored to the bottom after the stream ─────
// (within 40 px to absorb sub-pixel layout drift)
await browser.waitUntil(
async () => {
const metrics = await scrollMetrics();
if (!metrics.found) return false;
return metrics.scrollHeight > metrics.clientHeight;
},
{ timeout: 10_000, timeoutMsg: 'chat messages scroll container never overflowed' }
);
const atBottom = await scrollMetrics();
console.log(
`[chat-harness-scroll-render] bottom metrics: scrollTop=${atBottom.scrollTop}, scrollHeight=${atBottom.scrollHeight}, clientHeight=${atBottom.clientHeight}`
);
expect(atBottom.scrollHeight).toBeGreaterThan(atBottom.clientHeight);
expect(atBottom.scrollHeight - (atBottom.scrollTop + atBottom.clientHeight)).toBeLessThan(40);
@@ -190,6 +194,9 @@ describe('Chat harness — scroll + markdown render', () => {
await scrollMessageColumn(targetTop);
await browser.pause(500); // let the stick hook react
const afterScrollUp = await scrollMetrics();
console.log(
`[chat-harness-scroll-render] after manual scroll: scrollTop=${afterScrollUp.scrollTop}, scrollHeight=${afterScrollUp.scrollHeight}, clientHeight=${afterScrollUp.clientHeight}, targetTop=${targetTop}`
);
expect(Math.abs(afterScrollUp.scrollTop - targetTop)).toBeLessThan(40);
expect(
afterScrollUp.scrollHeight - (afterScrollUp.scrollTop + afterScrollUp.clientHeight)
@@ -104,6 +104,16 @@ async function snapshotRuntime(threadId: string): Promise<RuntimeSnapshot> {
}, threadId)) as RuntimeSnapshot;
}
async function hasRenderedSubagentTimeline(): Promise<boolean> {
return (await browser.execute(() => {
const rows = Array.from(document.querySelectorAll('[data-testid="agent-timeline-row"]'));
return rows.some(row => {
const text = row.textContent ?? '';
return /Research|Researching|subagent/i.test(text);
});
})) as boolean;
}
describe('Chat harness — orchestrator → subagent flow', () => {
before(async function beforeSuite() {
this.timeout(90_000);
@@ -114,6 +124,13 @@ describe('Chat harness — orchestrator → subagent flow', () => {
// 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 });
const superContext = await callOpenhumanRpc('openhuman.config_set_super_context_enabled', {
value: false,
});
expect(superContext.ok).toBe(true);
console.log(
'[chat-harness-subagent] Disabled super context for deterministic scripted LLM calls'
);
setMockBehavior('llmForcedResponses', JSON.stringify(FORCED_RESPONSES));
// Faster streaming for non-tool-call responses so this spec doesn't
@@ -170,10 +187,15 @@ describe('Chat harness — orchestrator → subagent flow', () => {
sawSubagentTimeline = true;
}
if (sawSubagentPhase && sawSubagentTimeline) break;
if (await textExists(CANARY_FINAL)) break;
if (await textExists(CANARY_FINAL)) {
sawSubagentTimeline = sawSubagentTimeline || (await hasRenderedSubagentTimeline());
break;
}
await browser.pause(200);
}
sawSubagentTimeline = sawSubagentTimeline || (await hasRenderedSubagentTimeline());
// At least ONE of the two signals must have fired — the timeline
// entry is the more durable check (the live phase can flip back to
// 'thinking' or 'idle' before our 200ms poll catches it).
@@ -31,7 +31,7 @@ import {
waitForSocketConnected,
} from '../helpers/chat-harness';
import { callOpenhumanRpc } from '../helpers/core-rpc';
import { clickText, clickToggle, textExists } from '../helpers/element-helpers';
import { clickText, textExists } from '../helpers/element-helpers';
import { resetApp } from '../helpers/reset-app';
import { navigateViaHash } from '../helpers/shared-flows';
import {
@@ -53,7 +53,7 @@ const FORCED_RESPONSES = [
toolCalls: [
{
id: 'call_delegate_do_crypto_1',
name: 'delegate_do_crypto',
name: 'do_crypto',
arguments: JSON.stringify({
prompt: `Prepare a $5 EVM transfer to John at ${JOHN_ADDRESS}.`,
}),
@@ -87,14 +87,17 @@ const FORCED_RESPONSES = [
];
async function clickRecoveryConsentCheckbox(): Promise<void> {
const checkbox = await browser.$('input[type="checkbox"]');
const checkbox = await browser.$('#mnemonic-confirm-checkbox');
if (!(await checkbox.isExisting())) {
throw new Error('Recovery phrase consent checkbox not found');
}
if (!(await checkbox.isSelected())) {
await clickToggle();
await checkbox.click();
}
await expect(checkbox).toBeSelected();
await browser.waitUntil(async () => await checkbox.isSelected(), {
timeout: 5_000,
timeoutMsg: 'Recovery phrase consent checkbox did not become selected',
});
}
describe('Chat harness — wallet flow', () => {
@@ -106,6 +109,13 @@ describe('Chat harness — wallet flow', () => {
// 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 });
const superContext = await callOpenhumanRpc('openhuman.config_set_super_context_enabled', {
value: false,
});
expect(superContext.ok).toBe(true);
console.log(
'[chat-harness-wallet-flow] Disabled super context for deterministic scripted LLM calls'
);
});
after(async () => {
@@ -61,6 +61,11 @@ describe('User journey — full research task', () => {
await startMockServer();
await waitForApp();
await resetApp(USER_ID);
const superContext = await callOpenhumanRpc('openhuman.config_set_super_context_enabled', {
value: false,
});
expect(superContext.ok).toBe(true);
console.log(`${LOG_PREFIX} Disabled super context for deterministic scripted LLM calls`);
setMockBehavior('llmForcedResponses', JSON.stringify(FORCED_RESPONSES));
setMockBehavior('llmStreamChunkDelayMs', '10');
@@ -111,7 +116,8 @@ describe('User journey — full research task', () => {
console.log(`${LOG_PREFIX} J1.1: passed — user message visible`);
});
it('J1.2 — tool call timeline appears during execution', async () => {
it('J1.2 — tool call timeline appears during execution', async function () {
this.timeout(60_000);
console.log(`${LOG_PREFIX} J1.2: watching for tool timeline entry`);
let sawToolTimeline = false;
const deadline = Date.now() + 45_000;
@@ -142,7 +148,8 @@ describe('User journey — full research task', () => {
console.log(`${LOG_PREFIX} J1.2: passed`);
});
it('J1.3 — final answer with canary text renders', async () => {
it('J1.3 — final answer with canary text renders', async function () {
this.timeout(60_000);
console.log(`${LOG_PREFIX} J1.3: waiting for canary`);
await browser.waitUntil(async () => await textExists(CANARY_FINAL), {
timeout: 45_000,
@@ -151,7 +158,8 @@ describe('User journey — full research task', () => {
console.log(`${LOG_PREFIX} J1.3: passed — canary visible`);
});
it('J1.4 — after navigate away + back, thread messages still visible', async () => {
it('J1.4 — after navigate away + back, thread messages still visible', async function () {
this.timeout(60_000);
console.log(`${LOG_PREFIX} J1.4: navigating away to /home`);
// Ensure the IN_FLIGHT map cleared (turn is fully done) before navigating.