From faa8393447ffe486ea883f92ab311adfaf1595b1 Mon Sep 17 00:00:00 2001 From: jbold Date: Sun, 8 Feb 2026 08:43:12 -0600 Subject: [PATCH 1/4] test: add full rust/wasm/e2e suite with ci gates --- .cargo/audit.toml | 12 +++ .github/workflows/claude-code-review.yml | 7 +- .github/workflows/claude.yml | 6 +- .github/workflows/test-suite.yml | 113 +++++++++++++++++++ .gitignore | 2 + Cargo.lock | 64 +++++++++++ README.md | 13 +++ TESTING.md | 62 +++++++++++ deny.toml | 23 ++++ e2e/auth-flow.spec.ts | 16 +++ package-lock.json | 78 ++++++++++++++ package.json | 15 +++ playwright.config.ts | 34 ++++++ scripts/run-e2e-server.sh | 21 ++++ scripts/test-all.sh | 7 ++ scripts/test-e2e.sh | 15 +++ scripts/test-rust.sh | 41 +++++++ scripts/test-wasm-ui.sh | 13 +++ src/bus/mod.rs | 32 ++++++ tests/gateway_http_test.rs | 132 +++++++++++++++++++++++ tests/store_test.rs | 67 ++++++++++++ tests/types_test.rs | 125 +++++++++++++++++++++ ui/Cargo.toml | 4 + ui/src/components/auth_prompt.rs | 5 +- ui/src/components/input.rs | 2 + ui/src/components/status.rs | 2 +- ui/src/lib.rs | 4 +- ui/src/ws.rs | 62 ++++++++++- ui/tests/wasm.rs | 49 +++++++++ 29 files changed, 1017 insertions(+), 9 deletions(-) create mode 100644 .cargo/audit.toml create mode 100644 .github/workflows/test-suite.yml create mode 100644 TESTING.md create mode 100644 deny.toml create mode 100644 e2e/auth-flow.spec.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100755 scripts/run-e2e-server.sh create mode 100755 scripts/test-all.sh create mode 100755 scripts/test-e2e.sh create mode 100755 scripts/test-rust.sh create mode 100755 scripts/test-wasm-ui.sh create mode 100644 tests/gateway_http_test.rs create mode 100644 tests/store_test.rs create mode 100644 tests/types_test.rs create mode 100644 ui/tests/wasm.rs diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..36d250e --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,12 @@ +[advisories] +ignore = [ + # extism transitively pins wasmtime 37.x today. + "RUSTSEC-2026-0006", + # transitive via wasmtime profiling stack + "RUSTSEC-2025-0057", + # transitive via async-nats / rustls-native-certs + "RUSTSEC-2025-0134", + # transitive in current leptos dependency graph + "RUSTSEC-2024-0436", +] + diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd..fc93a7a 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -10,6 +10,10 @@ on: # - "src/**/*.js" # - "src/**/*.jsx" +concurrency: + group: claude-review-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: claude-review: # Optional: Filter by PR author @@ -18,7 +22,9 @@ jobs: # github.event.pull_request.user.login == 'new-developer' || # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read pull-requests: read @@ -41,4 +47,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267..f4646ff 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -10,6 +10,10 @@ on: pull_request_review: types: [submitted] +concurrency: + group: claude-code-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }} + cancel-in-progress: true + jobs: claude: if: | @@ -18,6 +22,7 @@ jobs: (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read pull-requests: read @@ -47,4 +52,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml new file mode 100644 index 0000000..63c4da6 --- /dev/null +++ b/.github/workflows/test-suite.yml @@ -0,0 +1,113 @@ +name: Test Suite + +on: + pull_request: + push: + branches: + - main + +jobs: + rust-tests: + name: Rust + Coverage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Rust test tooling + uses: taiki-e/install-action@v2 + with: + tool: cargo-nextest,cargo-llvm-cov + + - name: Install llvm-tools + run: rustup component add llvm-tools-preview + + - name: Run backend tests and coverage gate + run: ./scripts/test-rust.sh + + security: + name: Dependency Security + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install security tooling + uses: taiki-e/install-action@v2 + with: + tool: cargo-audit,cargo-deny + + - name: Run cargo-audit + run: cargo audit --deny warnings + + - name: Run cargo-deny + run: cargo deny check advisories bans sources + + wasm-ui-tests: + name: WASM UI Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Rust (wasm target) + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + uses: taiki-e/install-action@v2 + with: + tool: wasm-pack + + - name: Run WASM UI tests + run: ./scripts/test-wasm-ui.sh + + e2e-tests: + name: Playwright E2E + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install JavaScript dependencies + run: npm install + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Set up Rust (wasm target) + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install trunk + uses: taiki-e/install-action@v2 + with: + tool: trunk + + - name: Run Playwright E2E tests + env: + EXOCLAW_E2E_PORT: "7210" + EXOCLAW_E2E_TOKEN: "e2e-test-token" + run: npm run test:e2e + + - name: Upload Playwright artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: output/playwright + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index d086f9e..47895e0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ Thumbs.db *.tmp *.swp .vscode/ +node_modules/ +output/playwright/ diff --git a/Cargo.lock b/Cargo.lock index 0b32357..80e334e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1188,6 +1188,7 @@ version = "0.1.0" dependencies = [ "futures", "gloo-net", + "gloo-timers", "js-sys", "leptos", "leptos_meta", @@ -1198,6 +1199,7 @@ dependencies = [ "serde_json", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-bindgen-test", "web-sys", ] @@ -1555,6 +1557,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gloo-utils" version = "0.2.0" @@ -2394,6 +2408,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2484,6 +2508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4764,6 +4789,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45649196a53b0b7a15101d845d44d2dda7374fc1b5b5e2bbf58b7577ff4b346d" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f579cdd0123ac74b94e1a4a72bd963cf30ebac343f2df347da0b8df24cdebed2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea" + [[package]] name = "wasm-encoder" version = "0.239.0" diff --git a/README.md b/README.md index d3aa182..ef15986 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,19 @@ cargo run -- plugin load ./plugins/telegram.wasm The gateway binds to `127.0.0.1:7200` by default. When binding to a non-loopback address, an auth token is required (via `--token` or `EXOCLAW_TOKEN` env var). +## Testing + +See `TESTING.md` for the full red/green workflow and CI layout. + +Quick commands: + +```bash +./scripts/test-rust.sh # backend + coverage gate +./scripts/test-wasm-ui.sh # wasm-bindgen-test via wasm-pack +./scripts/test-e2e.sh # Playwright browser flows +./scripts/test-all.sh # full stack +``` + ## Project status **Early development. Not production ready.** diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..84b0d38 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,62 @@ +# Testing and TDD + +This repository uses a layered test stack: + +- Backend/unit/integration: `cargo-nextest` +- Coverage gate: `cargo-llvm-cov` +- UI Rust-to-WASM tests: `wasm-pack` + `wasm-bindgen-test` +- Browser E2E: Playwright +- Dependency security: `cargo-audit` + `cargo-deny` + +## Prerequisites + +```bash +rustup target add wasm32-unknown-unknown +cargo install cargo-nextest cargo-llvm-cov wasm-pack trunk +npm install +npx playwright install chromium +``` + +## Test Commands + +```bash +# Backend tests + line coverage gate (default: 70%) +./scripts/test-rust.sh + +# WASM UI tests (runs ui/tests/*.rs in Node) +./scripts/test-wasm-ui.sh + +# Browser E2E tests +./scripts/test-e2e.sh + +# Full suite +./scripts/test-all.sh +``` + +Set a stricter coverage gate locally: + +```bash +COVERAGE_MIN_LINES=75 ./scripts/test-rust.sh +``` + +## Red/Green Workflow + +1. Write a failing test in the right layer first: + - backend behavior: `tests/*.rs` or module `#[cfg(test)]` + - UI parser/transform logic: `ui/tests/*.rs` with `#[wasm_bindgen_test]` + - full user flow: `e2e/*.spec.ts` +2. Run the smallest relevant command. +3. Implement the behavior. +4. Re-run targeted tests. +5. Run `./scripts/test-all.sh` before merging. + +## CI + +GitHub Actions workflow: `.github/workflows/test-suite.yml` + +Jobs: + +- `rust-tests`: nextest + coverage gate +- `security`: cargo-audit + cargo-deny +- `wasm-ui-tests`: wasm-pack tests +- `e2e-tests`: Playwright E2E against the real gateway + embedded UI diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..c4696be --- /dev/null +++ b/deny.toml @@ -0,0 +1,23 @@ +[graph] +all-features = true + +[advisories] +ignore = [ + # extism transitively pins wasmtime 37.x today. + # Track upgrade path to a patched wasmtime line. + "RUSTSEC-2026-0006", + # transitive via wasmtime profiling stack + "RUSTSEC-2025-0057", + # transitive via async-nats / rustls-native-certs + "RUSTSEC-2025-0134", +] + +[bans] +multiple-versions = "warn" +wildcards = "allow" +highlight = "all" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/e2e/auth-flow.spec.ts b/e2e/auth-flow.spec.ts new file mode 100644 index 0000000..27a9304 --- /dev/null +++ b/e2e/auth-flow.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "@playwright/test"; + +const token = process.env.EXOCLAW_E2E_TOKEN ?? "e2e-test-token"; + +test("prompts for auth and reconnects with a token", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByTestId("auth-overlay")).toBeVisible(); + await page.getByTestId("auth-token-input").fill(token); + await page.getByTestId("auth-connect-button").click(); + await expect(page.getByTestId("auth-overlay")).toBeHidden(); + + await page.getByTestId("connection-status").click(); + await expect(page.getByText("Connected")).toBeVisible(); +}); + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2ebc94b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "exoclaw-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "exoclaw-e2e", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "^1.50.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f025b02 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "exoclaw-e2e", + "private": true, + "version": "0.1.0", + "description": "E2E tests for exoclaw gateway + Leptos UI", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug" + }, + "devDependencies": { + "@playwright/test": "^1.50.1" + } +} + diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..9a45a21 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from "@playwright/test"; + +const port = Number(process.env.EXOCLAW_E2E_PORT ?? 7210); + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + timeout: 45_000, + expect: { + timeout: 10_000, + }, + outputDir: "output/playwright/artifacts", + reporter: [ + ["list"], + ["html", { outputFolder: "output/playwright/report", open: "never" }], + ], + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + webServer: { + command: "./scripts/run-e2e-server.sh", + url: `http://127.0.0.1:${port}/health`, + timeout: 180_000, + reuseExistingServer: !process.env.CI, + stdout: "pipe", + stderr: "pipe", + }, +}); + diff --git a/scripts/run-e2e-server.sh b/scripts/run-e2e-server.sh new file mode 100755 index 0000000..b6f51bb --- /dev/null +++ b/scripts/run-e2e-server.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v trunk >/dev/null 2>&1; then + echo "trunk is required. Install with: cargo install trunk" >&2 + exit 1 +fi + +port="${EXOCLAW_E2E_PORT:-7210}" +token="${EXOCLAW_E2E_TOKEN:-e2e-test-token}" + +# Playwright sets NO_COLOR=1 in some environments. Trunk's clap-based parser +# rejects that value for its no-color bool env handling. +unset NO_COLOR + +( + cd ui + trunk build +) + +exec cargo run --quiet -- gateway --bind 0.0.0.0 --port "${port}" --token "${token}" diff --git a/scripts/test-all.sh b/scripts/test-all.sh new file mode 100755 index 0000000..4a70cff --- /dev/null +++ b/scripts/test-all.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +./scripts/test-rust.sh +./scripts/test-wasm-ui.sh +./scripts/test-e2e.sh + diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh new file mode 100755 index 0000000..bfecee5 --- /dev/null +++ b/scripts/test-e2e.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v npx >/dev/null 2>&1; then + echo "npx is required. Install Node.js/npm first." >&2 + exit 1 +fi + +if [[ ! -d node_modules/@playwright/test ]]; then + echo "Playwright dependencies missing. Run: npm install" >&2 + exit 1 +fi + +npx playwright test + diff --git a/scripts/test-rust.sh b/scripts/test-rust.sh new file mode 100755 index 0000000..9a073e0 --- /dev/null +++ b/scripts/test-rust.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +coverage_min_lines="${COVERAGE_MIN_LINES:-70}" + +if ! command -v cargo-nextest >/dev/null 2>&1; then + echo "cargo-nextest is required. Install with: cargo install cargo-nextest" >&2 + exit 1 +fi + +if ! command -v cargo-llvm-cov >/dev/null 2>&1; then + echo "cargo-llvm-cov is required. Install with: cargo install cargo-llvm-cov" >&2 + exit 1 +fi + +if command -v rustup >/dev/null 2>&1; then + if ! rustup component list --installed | grep -q '^llvm-tools'; then + rustup component add llvm-tools-preview + fi +fi + +# Backend tests compile rust-embed assets from ui/dist. Keep a minimal +# placeholder so CI can run Rust tests without building the full UI bundle. +mkdir -p ui/dist +if [[ ! -f ui/dist/index.html ]]; then + cat > ui/dist/index.html <<'EOF' + + +exoclaw +UI placeholder for Rust test compile + +EOF +fi + +cargo nextest run --workspace --exclude exoclaw-ui +cargo llvm-cov \ + --workspace \ + --exclude exoclaw-ui \ + --ignore-filename-regex 'src/main.rs$' \ + --summary-only \ + --fail-under-lines "${coverage_min_lines}" diff --git a/scripts/test-wasm-ui.sh b/scripts/test-wasm-ui.sh new file mode 100755 index 0000000..ef7841f --- /dev/null +++ b/scripts/test-wasm-ui.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v wasm-pack >/dev/null 2>&1; then + echo "wasm-pack is required. Install with: cargo install wasm-pack" >&2 + exit 1 +fi + +( + cd ui + wasm-pack test --node +) + diff --git a/src/bus/mod.rs b/src/bus/mod.rs index 0e81d90..eb5a7e0 100644 --- a/src/bus/mod.rs +++ b/src/bus/mod.rs @@ -51,3 +51,35 @@ impl Default for MessageBus { Self::new() } } + +#[cfg(test)] +mod tests { + use super::MessageBus; + + #[test] + fn new_and_default_start_disconnected() { + let bus = MessageBus::new(); + assert!(!bus.is_connected()); + + let bus_default = MessageBus::default(); + assert!(!bus_default.is_connected()); + } + + #[tokio::test] + async fn publish_without_connection_is_noop() { + let bus = MessageBus::new(); + bus.publish("exoclaw.web.account.peer", b"payload") + .await + .expect("publish should be a no-op when disconnected"); + assert!(!bus.is_connected()); + } + + #[tokio::test] + async fn connect_to_unreachable_server_falls_back_to_local_mode() { + let mut bus = MessageBus::new(); + bus.connect("nats://127.0.0.1:1") + .await + .expect("unreachable nats should not hard-fail"); + assert!(!bus.is_connected()); + } +} diff --git a/tests/gateway_http_test.rs b/tests/gateway_http_test.rs new file mode 100644 index 0000000..e0ade22 --- /dev/null +++ b/tests/gateway_http_test.rs @@ -0,0 +1,132 @@ +use exoclaw::config::ExoclawConfig; +use tokio::time::{Duration, sleep}; + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind ephemeral") + .local_addr() + .expect("local addr") + .port() +} + +fn loopback_config(port: u16) -> ExoclawConfig { + let mut config = ExoclawConfig::default(); + config.gateway.bind = "127.0.0.1".to_string(); + config.gateway.port = port; + config +} + +async fn wait_for_health(port: u16) { + let client = reqwest::Client::new(); + let url = format!("http://127.0.0.1:{port}/health"); + + for _ in 0..80 { + if let Ok(resp) = client.get(&url).send().await { + if resp.status().is_success() { + return; + } + } + sleep(Duration::from_millis(50)).await; + } + + panic!("gateway did not become healthy at {url}"); +} + +#[tokio::test] +async fn run_rejects_non_loopback_without_token() { + let mut config = ExoclawConfig::default(); + config.gateway.bind = "0.0.0.0".to_string(); + config.gateway.port = free_port(); + + let err = exoclaw::gateway::run(config, None) + .await + .expect_err("non-loopback run without token must fail"); + assert!(err.to_string().contains("Auth token required")); +} + +#[tokio::test] +async fn health_endpoint_returns_ok() { + let port = free_port(); + let config = loopback_config(port); + let gateway = tokio::spawn(async move { + let _ = exoclaw::gateway::run(config, None).await; + }); + + wait_for_health(port).await; + + let url = format!("http://127.0.0.1:{port}/health"); + let response = reqwest::get(url).await.expect("health response"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body = response.text().await.expect("health body"); + assert_eq!(body, "ok"); + + gateway.abort(); + let _ = gateway.await; +} + +#[tokio::test] +async fn ui_root_and_spa_fallback_routes_serve_html() { + let port = free_port(); + let config = loopback_config(port); + let gateway = tokio::spawn(async move { + let _ = exoclaw::gateway::run(config, None).await; + }); + + wait_for_health(port).await; + + let client = reqwest::Client::new(); + let root = client + .get(format!("http://127.0.0.1:{port}/")) + .send() + .await + .expect("root response"); + assert_eq!(root.status(), reqwest::StatusCode::OK); + let root_content_type = root + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(root_content_type.contains("text/html")); + + let fallback = client + .get(format!("http://127.0.0.1:{port}/app/some/client-route")) + .send() + .await + .expect("fallback response"); + assert_eq!(fallback.status(), reqwest::StatusCode::OK); + let fallback_content_type = fallback + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(fallback_content_type.contains("text/html")); + + gateway.abort(); + let _ = gateway.await; +} + +#[tokio::test] +async fn webhook_without_adapter_returns_not_found() { + let port = free_port(); + let config = loopback_config(port); + let gateway = tokio::spawn(async move { + let _ = exoclaw::gateway::run(config, None).await; + }); + + wait_for_health(port).await; + + let client = reqwest::Client::new(); + let response = client + .post(format!("http://127.0.0.1:{port}/webhook/discord")) + .body(r#"{"content":"hello"}"#) + .send() + .await + .expect("webhook response"); + + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); + let body = response.text().await.expect("webhook body"); + assert!(body.contains("no channel adapter for 'discord'")); + + gateway.abort(); + let _ = gateway.await; +} diff --git a/tests/store_test.rs b/tests/store_test.rs new file mode 100644 index 0000000..2d14746 --- /dev/null +++ b/tests/store_test.rs @@ -0,0 +1,67 @@ +use exoclaw::store::SessionStore; +use serde_json::json; + +#[test] +fn new_and_default_start_empty() { + let store = SessionStore::new(); + assert_eq!(store.count(), 0); + + let default_store = SessionStore::default(); + assert_eq!(default_store.count(), 0); +} + +#[test] +fn get_or_create_reuses_existing_session() { + let mut store = SessionStore::new(); + let created_at = { + let session = store.get_or_create("web:acct:peer", "agent-a"); + assert_eq!(session.key, "web:acct:peer"); + assert_eq!(session.agent_id, "agent-a"); + assert_eq!(session.message_count, 0); + session.created_at + }; + + let session = store.get_or_create("web:acct:peer", "agent-b"); + assert_eq!(session.agent_id, "agent-a"); + assert_eq!(session.created_at, created_at); + assert_eq!(store.count(), 1); +} + +#[test] +fn append_message_tracks_count_and_ignores_missing_session() { + let mut store = SessionStore::new(); + store.append_message("missing", json!({"role":"user","content":"ignored"})); + assert!(store.get("missing").is_none()); + + store.get_or_create("web:acct:peer", "agent-a"); + store.append_message("web:acct:peer", json!({"role":"user","content":"hello"})); + store.append_message( + "web:acct:peer", + json!({"role":"assistant","content":"world"}), + ); + + let session = store.get("web:acct:peer").expect("session should exist"); + assert_eq!(session.message_count, 2); + assert_eq!(session.messages.len(), 2); +} + +#[test] +fn get_mut_and_sessions_mut_allow_updates() { + let mut store = SessionStore::new(); + store.get_or_create("web:acct:peer", "agent-a"); + + if let Some(session) = store.get_mut("web:acct:peer") { + session.agent_id = "agent-b".to_string(); + } + + let clone_for_other_key = store.get("web:acct:peer").expect("session").clone(); + store + .sessions_mut() + .insert("other:key".to_string(), clone_for_other_key); + + assert_eq!(store.count(), 2); + assert_eq!( + store.get("web:acct:peer").expect("session").agent_id, + "agent-b" + ); +} diff --git a/tests/types_test.rs b/tests/types_test.rs new file mode 100644 index 0000000..27d8ac1 --- /dev/null +++ b/tests/types_test.rs @@ -0,0 +1,125 @@ +use exoclaw::types::{AgentMessage, Message, MessageContent, StreamEvent}; +use serde_json::json; + +#[test] +fn text_message_constructor_sets_defaults() { + let msg = Message::text("user", "hello"); + assert_eq!(msg.role, "user"); + assert!(matches!(msg.content, MessageContent::Text { .. })); + assert!(msg.token_count.is_none()); +} + +#[test] +fn provider_message_for_text() { + let msg = Message::text("user", "hello"); + let provider = msg.as_provider_message().expect("provider message"); + assert_eq!(provider["role"], "user"); + assert_eq!(provider["content"], "hello"); +} + +#[test] +fn provider_message_for_tool_use() { + let msg = Message { + role: "assistant".into(), + content: MessageContent::ToolUse { + id: "call-1".into(), + name: "search".into(), + input: json!({"q":"rust"}), + }, + timestamp: chrono::Utc::now(), + token_count: Some(42), + }; + + let provider = msg.as_provider_message().expect("provider message"); + assert_eq!(provider["role"], "assistant"); + assert_eq!(provider["content"][0]["type"], "tool_use"); + assert_eq!(provider["content"][0]["id"], "call-1"); + assert_eq!(provider["content"][0]["name"], "search"); + assert_eq!(provider["content"][0]["input"]["q"], "rust"); +} + +#[test] +fn provider_message_for_tool_result() { + let msg = Message { + role: "user".into(), + content: MessageContent::ToolResult { + tool_use_id: "call-1".into(), + content: "done".into(), + is_error: true, + }, + timestamp: chrono::Utc::now(), + token_count: None, + }; + + let provider = msg.as_provider_message().expect("provider message"); + assert_eq!(provider["role"], "user"); + assert_eq!(provider["content"][0]["type"], "tool_result"); + assert_eq!(provider["content"][0]["tool_use_id"], "call-1"); + assert_eq!(provider["content"][0]["content"], "done"); + assert_eq!(provider["content"][0]["is_error"], true); +} + +#[test] +fn agent_message_defaults_peer_to_main() { + let raw = json!({ + "channel": "web", + "account": "acct", + "content": "hello", + "guild": null, + "team": null + }); + + let msg: AgentMessage = serde_json::from_value(raw).expect("deserialize AgentMessage"); + assert_eq!(msg.peer, "main"); + assert_eq!(msg.channel, "web"); + assert_eq!(msg.account, "acct"); + assert_eq!(msg.content, "hello"); +} + +#[test] +fn stream_event_to_frame_formats_wire_payloads() { + let request_id = "req-1"; + + let text = StreamEvent::Text("chunk".into()).to_frame(request_id); + assert_eq!(text["id"], request_id); + assert_eq!(text["event"], "text"); + assert_eq!(text["data"], "chunk"); + + let tool_use = StreamEvent::ToolUse { + id: "call-2".into(), + name: "lookup".into(), + input: json!({"x":1}), + } + .to_frame(request_id); + assert_eq!(tool_use["event"], "tool_use"); + assert_eq!(tool_use["data"]["id"], "call-2"); + assert_eq!(tool_use["data"]["name"], "lookup"); + assert_eq!(tool_use["data"]["input"]["x"], 1); + + let tool_result = StreamEvent::ToolResult { + tool_use_id: "call-2".into(), + content: "ok".into(), + is_error: false, + } + .to_frame(request_id); + assert_eq!(tool_result["event"], "tool_result"); + assert_eq!(tool_result["data"]["tool_use_id"], "call-2"); + assert_eq!(tool_result["data"]["content"], "ok"); + assert_eq!(tool_result["data"]["is_error"], false); + + let usage = StreamEvent::Usage { + input_tokens: 10, + output_tokens: 4, + } + .to_frame(request_id); + assert_eq!(usage["event"], "usage"); + assert_eq!(usage["data"]["input_tokens"], 10); + assert_eq!(usage["data"]["output_tokens"], 4); + + let done = StreamEvent::Done.to_frame(request_id); + assert_eq!(done["event"], "done"); + + let error = StreamEvent::Error("boom".into()).to_frame(request_id); + assert_eq!(error["event"], "error"); + assert_eq!(error["data"], "boom"); +} diff --git a/ui/Cargo.toml b/ui/Cargo.toml index b339e69..34e87d0 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -12,6 +12,7 @@ leptos = { version = "0.7", features = ["csr"] } leptos_router = { version = "0.7" } leptos_meta = { version = "0.7" } gloo-net = { version = "0.6", features = ["websocket"] } +gloo-timers = { version = "0.3", features = ["futures"] } pulldown-cmark = "0.12" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -31,3 +32,6 @@ web-sys = { version = "0.3", features = [ js-sys = "0.3" futures = "0.3" log = "0.4" + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/ui/src/components/auth_prompt.rs b/ui/src/components/auth_prompt.rs index bbcbf62..877104b 100644 --- a/ui/src/components/auth_prompt.rs +++ b/ui/src/components/auth_prompt.rs @@ -28,13 +28,14 @@ pub fn AuthPrompt() -> impl IntoView { }; view! { -
+

"Authentication Required"

"Enter your gateway token to connect."

impl IntoView { } on:keydown=on_keydown /> -
diff --git a/ui/src/components/input.rs b/ui/src/components/input.rs index 8e6f96c..77ace41 100644 --- a/ui/src/components/input.rs +++ b/ui/src/components/input.rs @@ -112,6 +112,7 @@ pub fn MessageInput() -> impl IntoView {