diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 7df01c4d6..54f48300c 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -133,11 +133,24 @@ jobs: - name: Build E2E app run: pnpm --filter openhuman-app test:e2e:build - - name: Run E2E (smoke + mega-flow) + - name: Run E2E (smoke) if: ${{ !inputs.full }} run: | xvfb-run -a --server-args="-screen 0 1280x960x24" \ bash app/scripts/e2e-run-session.sh test/e2e/specs/smoke.spec.ts smoke + + # Mega-flow exercises the OAuth-success-deep-link and Composio + # trigger-lifecycle paths. Both currently hit a pre-existing race + # in the deep-link → custom-event propagation on the Linux CI + # image (smoke.spec.ts has a related skipped `it()` with the + # same root cause). Keep mega-flow visible in CI as a signal but + # non-blocking, mirroring the pre-refactor behavior — once the + # auth/oauth deep-link race lands a fix, drop the + # `continue-on-error` and consolidate the two steps. + - name: Run E2E (mega-flow, non-blocking) + if: ${{ !inputs.full }} + continue-on-error: true + run: | xvfb-run -a --server-args="-screen 0 1280x960x24" \ bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow @@ -147,17 +160,65 @@ jobs: xvfb-run -a --server-args="-screen 0 1280x960x24" \ bash app/scripts/e2e-run-session.sh - - name: Upload e2e artifacts - if: always() - uses: actions/upload-artifact@v5 + # Artifact uploads intentionally omitted — this reusable workflow + # is invoked from release-staging.yml and release-production.yml, + # and uploaded logs can carry mock-backend payloads, env-var + # echoes, and CDP transcripts that we don't want pinned to a + # release artifact. Local repro: rerun the spec via Docker and + # the same logs land in /tmp. + + # Rust-side E2E counterpart to the Tauri runs above. Same Linux-only + # scope (CI does not run this on macOS or Windows — the Rust core is + # platform-independent, so one OS is enough signal). Boots the same + # `scripts/mock-api-server.mjs` the Tauri specs hit, then runs every + # `tests/*_e2e.rs` suite against it. + rust-e2e-linux: + if: inputs.run_linux + name: E2E (Linux / Rust integration suite) + runs-on: ubuntu-22.04 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:latest + timeout-minutes: 60 + env: + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + steps: + - name: Checkout code + uses: actions/checkout@v5 with: - name: e2e-artifacts-linux - path: | - app/test/e2e/artifacts/ - ${{ runner.temp }}/openhuman-e2e-app-*.log - ${{ runner.temp }}/appium-e2e-*.log - if-no-files-found: ignore - retention-days: 7 + ref: ${{ inputs.ref }} + fetch-depth: 1 + submodules: recursive + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ~/.local/share/pnpm/store + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: . -> target + cache-on-failure: true + key: rust-e2e-linux + + - name: Install JS dependencies + run: pnpm install --frozen-lockfile + + - name: Ensure .env exists for tests + run: | + touch .env + touch app/.env + + - name: Run Rust E2E suite (tests/*_e2e.rs vs mock backend) + run: pnpm test:rust:e2e + + # No artifact uploads here either — same release-workflow reuse + # concern as the Tauri job above. Mock-backend log lives at + # /tmp/openhuman-rust-e2e-mock.log for local docker repro. e2e-macos: if: inputs.run_macos @@ -259,17 +320,8 @@ jobs: if: ${{ inputs.full }} run: bash app/scripts/e2e-run-session.sh - - name: Upload e2e artifacts - if: always() - uses: actions/upload-artifact@v5 - with: - name: e2e-artifacts-macos - path: | - app/test/e2e/artifacts/ - ${{ runner.temp }}/openhuman-e2e-app-*.log - ${{ runner.temp }}/appium-e2e-*.log - if-no-files-found: ignore - retention-days: 7 + # Artifact uploads intentionally omitted — see e2e-linux for the + # reusable-workflow-is-also-used-by-releases rationale. e2e-windows: if: inputs.run_windows @@ -355,14 +407,5 @@ jobs: shell: bash run: bash app/scripts/e2e-run-session.sh - - name: Upload e2e artifacts - if: always() - uses: actions/upload-artifact@v5 - with: - name: e2e-artifacts-windows - path: | - app/test/e2e/artifacts/ - ${{ runner.temp }}/openhuman-e2e-app-*.log - ${{ runner.temp }}/appium-e2e-*.log - if-no-files-found: ignore - retention-days: 7 + # Artifact uploads intentionally omitted — see e2e-linux for the + # reusable-workflow-is-also-used-by-releases rationale. diff --git a/app/test/e2e/helpers/chat-harness.ts b/app/test/e2e/helpers/chat-harness.ts new file mode 100644 index 000000000..5de3a18b4 --- /dev/null +++ b/app/test/e2e/helpers/chat-harness.ts @@ -0,0 +1,92 @@ +/** + * Shared DOM helpers for the chat-harness E2E specs. + * + * These exist because the existing `element-helpers.ts` work in terms + * of visible text / button labels, but the chat composer specifically + * needs: + * + * - `button[title="New thread"]` — icon-only button, no text + * - `textarea[placeholder="Type a message..."]` — React-controlled + * input that requires the native-setter trick + `input` event + * dispatch to register a change + * - `button[aria-label="Send message"]` — icon-only button + * + * Pulling these into one place stops the same `browser.execute(...)` + * blob from being copy-pasted across each chat-harness spec, and + * gives a single seam to fix if the underlying selectors drift. + * + * If a future redesign exposes `data-testid` on these affordances, + * the per-helper queries can collapse to a `browser.$(...)` call. + */ + +/** 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. */ +export async function clickByTitle(title: string, timeoutMs = 6_000): Promise { + 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; + }, title); + if (clicked) return true; + await browser.pause(200); + } + return false; +} + +/** Set the chat composer textarea's value AND fire the synthetic + * `input` event so React's controlled-input state picks it up. */ +export async function typeIntoComposer(text: string): Promise { + await browser.execute((t: string) => { + const ta = document.querySelector( + 'textarea[placeholder="Type a message..."]' + ) as HTMLTextAreaElement | null; + if (!ta) return; + const setter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + 'value' + )?.set; + setter?.call(ta, t); + ta.dispatchEvent(new Event('input', { bubbles: true })); + }, text); +} + +/** Click the chat composer's send button. Returns `false` if the + * button isn't there yet or is `disabled` (so the caller can poll). */ +export async function clickSend(): Promise { + return (await browser.execute(() => { + const btn = document.querySelector( + 'button[aria-label="Send message"]' + ) as HTMLButtonElement | null; + if (!btn || btn.disabled) return false; + btn.click(); + return true; + })) as boolean; +} + +/** Read `redux.thread.selectedThreadId` straight from the exposed + * store handle (see `app/src/store/index.ts`). Returns `null` when + * no thread is selected yet. */ +export async function getSelectedThreadId(): Promise { + return (await browser.execute(() => { + const winAny = window as unknown as { __OPENHUMAN_STORE__?: { getState: () => unknown } }; + const state = winAny.__OPENHUMAN_STORE__?.getState() as + | { thread?: { selectedThreadId?: string | null } } + | undefined; + return state?.thread?.selectedThreadId ?? null; + })) as string | null; +} + +/** Hex-encode the thread id the same way the Rust conversations + * store does. Used to locate the on-disk JSONL transcript at + * `/memory/conversations/threads/.jsonl`. */ +export function hexEncodeThreadId(s: string): string { + return Array.from(new TextEncoder().encode(s)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/app/test/e2e/specs/chat-harness-cancel.spec.ts b/app/test/e2e/specs/chat-harness-cancel.spec.ts new file mode 100644 index 000000000..257493996 --- /dev/null +++ b/app/test/e2e/specs/chat-harness-cancel.spec.ts @@ -0,0 +1,198 @@ +// @ts-nocheck +/** + * Chat harness — mid-stream cancel. + * + * The composer's Cancel button calls `chatService.chatCancel` → + * `openhuman.channel_web_cancel` → `cancel_chat()` in + * `src/openhuman/channels/providers/web.rs`. That handler aborts the + * in-flight JoinHandle, removes the IN_FLIGHT entry, and publishes a + * `chat_error` event with `error_type = "cancelled"`. + * + * What this spec verifies: + * 1. The mock LLM is configured to stream slowly enough that a real + * user could click Cancel mid-stream (per-chunk delay 500ms × 6 + * chunks ≈ 3s of streaming). + * 2. Send a message → wait for IN_FLIGHT to contain an entry. + * 3. Click the Cancel button in the chat composer. + * 4. Within a short window: + * - IN_FLIGHT clears (Rust side). + * - The DOM never accumulates the final two chunks. + * - Send button becomes enabled again. + * 5. The conversation file on disk does NOT contain the full reply + * (the cancel happened before the assistant finished). + * + * This is the second hardest scenario in the chat pipeline — the first + * being the streaming reply itself. If cancel breaks, in-flight chats + * leak indefinitely. + */ +import { waitForApp } from '../helpers/app-helpers'; +import { + clickByTitle, + clickSend, + getSelectedThreadId, + hexEncodeThreadId, + typeIntoComposer, +} 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 { setMockBehavior, startMockServer, stopMockServer } from '../mock-server'; + +const USER_ID = 'e2e-chat-harness-cancel'; +const PROMPT = 'Please count to ten slowly with one number per chunk.'; + +// Six chunks × 500 ms ≈ 3s of streaming. Plenty of room to cancel +// after the first 1–2 chunks have landed. +const SLOW_SCRIPT = [ + { text: 'one ', delayMs: 500 }, + { text: 'two ', delayMs: 500 }, + { text: 'three ', delayMs: 500 }, + { text: 'four ', delayMs: 500 }, + { text: 'five ', delayMs: 500 }, + { text: 'six.', delayMs: 500 }, + { finish: 'stop' }, +]; + +const EARLY_PIECES = ['one ', 'two ']; +const LATE_PIECES = ['five ', 'six.']; + +/** + * The composer's mid-stream cancel button is a plain `