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/examples/echo-plugin/Cargo.toml b/examples/echo-plugin/Cargo.toml index 266627b..0508fb9 100644 --- a/examples/echo-plugin/Cargo.toml +++ b/examples/echo-plugin/Cargo.toml @@ -11,3 +11,5 @@ crate-type = ["cdylib"] extism-pdk = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" + +[workspace] diff --git a/examples/mock-channel/Cargo.toml b/examples/mock-channel/Cargo.toml index 1ffe0b9..5a224d1 100644 --- a/examples/mock-channel/Cargo.toml +++ b/examples/mock-channel/Cargo.toml @@ -11,3 +11,5 @@ crate-type = ["cdylib"] extism-pdk = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" + +[workspace] 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..cb2c2ba --- /dev/null +++ b/scripts/test-rust.sh @@ -0,0 +1,61 @@ +#!/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 + +# Integration tests load fixture WASM plugins from examples/*/target/...; +# build them explicitly for clean CI runners. +if [[ -f examples/mock-channel/Cargo.toml || -f examples/echo-plugin/Cargo.toml ]]; then + if command -v rustup >/dev/null 2>&1; then + rustup target add wasm32-unknown-unknown + fi + if [[ -f examples/mock-channel/Cargo.toml ]]; then + cargo build \ + --manifest-path examples/mock-channel/Cargo.toml \ + --release \ + --target wasm32-unknown-unknown + fi + if [[ -f examples/echo-plugin/Cargo.toml ]]; then + cargo build \ + --manifest-path examples/echo-plugin/Cargo.toml \ + --release \ + --target wasm32-unknown-unknown + 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' + + +