From 50bdc62caa63838bf509490460a0dbc87d2c394c Mon Sep 17 00:00:00 2001 From: skoshx Date: Sun, 5 Apr 2026 15:01:51 +0300 Subject: [PATCH 1/3] feat: cleanup browser, mcp, liveviewer, run-test and tests --- .github/scripts/e2e.ts | 130 ++++ .github/workflows/e2e.yml | 2 + .github/workflows/test.yml | 322 +++++++++ .gitignore | 3 +- .gitmodules | 3 - .repos/effect | 2 +- EFFECT_CHECKLIST.md | 9 + apps/cli/package.json | 5 +- apps/cli/src/artifact-server.ts | 129 ++++ apps/cli/src/commands/add-github-action.ts | 14 +- apps/cli/src/commands/add-skill.ts | 42 +- apps/cli/src/commands/viewer.ts | 21 + apps/cli/src/commands/watch.ts | 1 - apps/cli/src/components/app.tsx | 6 +- .../screens/cookie-sync-confirm-screen.tsx | 93 +-- .../components/screens/main-menu-screen.tsx | 15 +- .../components/screens/port-picker-screen.tsx | 7 +- .../src/components/screens/results-screen.tsx | 5 +- .../screens/saved-flow-picker-screen.tsx | 6 +- .../src/components/screens/testing-screen.tsx | 36 +- .../src/components/screens/watch-screen.tsx | 7 +- apps/cli/src/components/ui/modeline.tsx | 6 +- apps/cli/src/data/execution-atom.ts | 270 +++----- apps/cli/src/hooks/use-config-options.ts | 6 +- apps/cli/src/hooks/use-installed-browsers.ts | 46 +- apps/cli/src/index.tsx | 102 ++- apps/cli/src/layers.ts | 72 +- apps/cli/src/replay-host.ts | 6 + apps/cli/src/stores/use-navigation.ts | 16 +- apps/cli/src/stores/use-preferences.ts | 2 - .../cli/src/stores/use-project-preferences.ts | 26 +- apps/cli/src/utils/ci-reporter.ts | 143 ---- apps/cli/src/utils/detect-projects.ts | 64 +- apps/cli/src/utils/extract-close-artifacts.ts | 68 -- apps/cli/src/utils/gha-output.ts | 49 -- apps/cli/src/utils/load-replay-events.ts | 42 -- apps/cli/src/utils/push-step-state.ts | 57 -- apps/cli/src/utils/replay-proxy-server.ts | 209 ------ apps/cli/src/utils/run-test.ts | 495 +++----------- apps/cli/src/utils/step-elapsed.ts | 16 - apps/cli/tests/add-skill.test.ts | 18 +- apps/cli/tests/artifact-proxy.test.ts | 94 +++ apps/cli/tests/load-replay-events.test.ts | 12 +- apps/cli/vite.config.ts | 14 +- apps/website/app/llms.txt/route.ts | 2 + apps/website/app/replay/page.tsx | 82 ++- .../components/replay/replay-viewer.tsx | 339 +++++++--- .../components/replay/test-selector.tsx | 102 +++ apps/website/lib/replay/atoms/live-updates.ts | 17 + .../website/lib/replay/atoms/selected-test.ts | 6 + apps/website/lib/replay/atoms/test-list.ts | 10 + apps/website/lib/replay/fetch-artifacts.ts | 19 + apps/website/lib/replay/injected-events.ts | 16 + apps/website/lib/replay/rpc-client.ts | 25 + apps/website/next-env.d.ts | 2 +- apps/website/next.config.ts | 5 +- apps/website/package.json | 3 + apps/website/pnpm-workspace.yaml | 3 - apps/website/scripts/record-demo.ts | 16 +- package.json | 5 +- packages/agent/src/acp-client.ts | 81 ++- packages/agent/src/agent.ts | 6 +- packages/agent/src/build-session-meta.ts | 17 +- packages/agent/src/index.ts | 1 - packages/agent/src/schemas/ai-sdk.ts | 13 - packages/agent/src/schemas/index.ts | 1 - packages/agent/tests/agent.test.ts | 9 +- .../agent/tests/build-session-meta.test.ts | 38 +- packages/agent/tsconfig.json | 3 +- packages/browser/package.json | 1 + packages/browser/src/accessibility.ts | 4 +- packages/browser/src/artifact-storage.ts | 46 ++ packages/browser/src/artifacts.ts | 36 + packages/browser/src/browser.ts | 521 --------------- packages/browser/src/cdp-discovery.ts | 4 +- packages/browser/src/errors.ts | 45 +- packages/browser/src/index.ts | 12 +- packages/browser/src/mcp-server.ts | 491 ++++++++++++++ packages/browser/src/mcp/artifact-client.ts | 23 + packages/browser/src/mcp/constants.ts | 4 +- packages/browser/src/mcp/index.ts | 8 +- packages/browser/src/mcp/live-view-server.ts | 246 ------- packages/browser/src/mcp/mcp-session.ts | 596 ----------------- packages/browser/src/mcp/runtime.ts | 14 - packages/browser/src/mcp/server.ts | 531 --------------- packages/browser/src/mcp/start.ts | 42 +- packages/browser/src/playwright.ts | 617 ++++++++++++++++++ packages/browser/src/recorder.ts | 58 -- packages/browser/src/replay-viewer.ts | 198 ------ packages/browser/src/rrvideo.ts | 207 ++++-- packages/browser/src/runtime/index.ts | 9 +- packages/browser/src/runtime/rrweb.ts | 4 + packages/browser/src/types.ts | 14 +- .../browser/src/utils/evaluate-runtime.ts | 8 +- packages/browser/tests/act.test.ts | 251 +++---- packages/browser/tests/browser-e2e.test.ts | 384 +++++------ packages/browser/tests/create-page.test.ts | 311 --------- .../browser/tests/live-view-server.test.ts | 85 --- packages/browser/tests/mcp-server.test.ts | 61 +- packages/browser/tests/playwright.test.ts | 97 +++ packages/browser/tests/recorder.test.ts | 68 -- packages/browser/tests/rrvideo.test.ts | 84 +-- packages/browser/tests/snapshot.test.ts | 539 +++++++-------- .../tests/viewport-aware-snapshot.test.ts | 275 -------- packages/browser/tsconfig.json | 3 +- packages/cookies/package.json | 3 +- packages/cookies/src/browser-config.ts | 2 +- packages/cookies/src/browser-detector.ts | 49 +- packages/cookies/src/cdp-client.ts | 8 +- packages/cookies/src/chromium-sqlite.ts | 4 +- packages/cookies/src/chromium.ts | 4 +- packages/cookies/src/cookies.ts | 4 +- packages/cookies/src/firefox.ts | 4 +- packages/cookies/src/index.ts | 16 +- packages/cookies/src/safari.ts | 4 +- packages/cookies/src/sqlite-client.ts | 2 +- packages/cookies/src/types.ts | 148 ----- packages/cookies/src/utils/binary-cookies.ts | 2 +- packages/cookies/tests/browsers.test.ts | 26 + packages/cookies/tests/cdp-client.test.ts | 140 ++-- packages/cookies/tests/cookies.test.ts | 2 +- packages/cookies/tests/services.test.ts | 5 +- packages/cookies/tsconfig.json | 5 +- packages/shared/package.json | 1 + packages/shared/src/analytics/analytics.ts | 16 +- packages/shared/src/constants.ts | 6 + packages/shared/src/index.ts | 5 + packages/shared/src/models.ts | 389 ++++++++++- .../shared/src/observability/agent-logger.ts | 4 +- packages/shared/src/prompts.ts | 5 +- packages/shared/src/rpc/artifact.rpc.ts | 34 + packages/shared/src/rpcs.ts | 1 + packages/shared/tests/dynamic-steps.test.ts | 2 +- packages/shared/tests/prompts.test.ts | 2 +- packages/shared/tests/to-plain-text.test.ts | 2 +- packages/shared/tsconfig.json | 3 +- packages/supervisor/package.json | 3 + packages/supervisor/src/artifact-store.ts | 122 ++++ packages/supervisor/src/constants.ts | 2 + packages/supervisor/src/detect-project.ts | 6 +- packages/supervisor/src/executor.ts | 125 ++-- packages/supervisor/src/github.ts | 6 +- packages/supervisor/src/index.ts | 3 + packages/supervisor/src/output-reporter.ts | 308 +++++++++ packages/supervisor/src/reporter.ts | 57 +- .../supervisor/src/rpc/artifact.rpc.layer.ts | 33 + packages/supervisor/src/tail.ts | 46 ++ packages/supervisor/src/watch.ts | 21 +- .../supervisor/tests/artifact-store.test.ts | 217 ++++++ .../supervisor/tests/detect-project.test.ts | 12 +- .../supervisor/tests/executor-e2e.test.ts | 176 +++++ packages/supervisor/tests/executor.test.ts | 4 +- packages/supervisor/tsconfig.json | 3 +- packages/typescript-sdk/src/expect.ts | 22 +- packages/typescript-sdk/src/layers.ts | 5 +- pnpm-lock.yaml | 487 +++++++++++++- 156 files changed, 6246 insertions(+), 5719 deletions(-) create mode 100644 .github/scripts/e2e.ts create mode 100644 .github/workflows/test.yml create mode 100644 EFFECT_CHECKLIST.md create mode 100644 apps/cli/src/artifact-server.ts create mode 100644 apps/cli/src/commands/viewer.ts create mode 100644 apps/cli/src/replay-host.ts delete mode 100644 apps/cli/src/utils/ci-reporter.ts delete mode 100644 apps/cli/src/utils/extract-close-artifacts.ts delete mode 100644 apps/cli/src/utils/gha-output.ts delete mode 100644 apps/cli/src/utils/load-replay-events.ts delete mode 100644 apps/cli/src/utils/push-step-state.ts delete mode 100644 apps/cli/src/utils/replay-proxy-server.ts delete mode 100644 apps/cli/src/utils/step-elapsed.ts create mode 100644 apps/cli/tests/artifact-proxy.test.ts create mode 100644 apps/website/components/replay/test-selector.tsx create mode 100644 apps/website/lib/replay/atoms/live-updates.ts create mode 100644 apps/website/lib/replay/atoms/selected-test.ts create mode 100644 apps/website/lib/replay/atoms/test-list.ts create mode 100644 apps/website/lib/replay/fetch-artifacts.ts create mode 100644 apps/website/lib/replay/injected-events.ts create mode 100644 apps/website/lib/replay/rpc-client.ts delete mode 100644 apps/website/pnpm-workspace.yaml delete mode 100644 packages/agent/src/schemas/ai-sdk.ts delete mode 100644 packages/agent/src/schemas/index.ts create mode 100644 packages/browser/src/artifact-storage.ts create mode 100644 packages/browser/src/artifacts.ts delete mode 100644 packages/browser/src/browser.ts create mode 100644 packages/browser/src/mcp-server.ts create mode 100644 packages/browser/src/mcp/artifact-client.ts delete mode 100644 packages/browser/src/mcp/live-view-server.ts delete mode 100644 packages/browser/src/mcp/mcp-session.ts delete mode 100644 packages/browser/src/mcp/runtime.ts delete mode 100644 packages/browser/src/mcp/server.ts create mode 100644 packages/browser/src/playwright.ts delete mode 100644 packages/browser/src/recorder.ts delete mode 100644 packages/browser/src/replay-viewer.ts delete mode 100644 packages/browser/tests/create-page.test.ts delete mode 100644 packages/browser/tests/live-view-server.test.ts create mode 100644 packages/browser/tests/playwright.test.ts delete mode 100644 packages/browser/tests/recorder.test.ts delete mode 100644 packages/browser/tests/viewport-aware-snapshot.test.ts delete mode 100644 packages/cookies/src/types.ts create mode 100644 packages/shared/src/rpc/artifact.rpc.ts create mode 100644 packages/shared/src/rpcs.ts create mode 100644 packages/supervisor/src/artifact-store.ts create mode 100644 packages/supervisor/src/output-reporter.ts create mode 100644 packages/supervisor/src/rpc/artifact.rpc.layer.ts create mode 100644 packages/supervisor/src/tail.ts create mode 100644 packages/supervisor/tests/artifact-store.test.ts create mode 100644 packages/supervisor/tests/executor-e2e.test.ts diff --git a/.github/scripts/e2e.ts b/.github/scripts/e2e.ts new file mode 100644 index 000000000..ce81c4693 --- /dev/null +++ b/.github/scripts/e2e.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import * as path from "node:path"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { NodeRuntime, NodeServices } from "@effect/platform-node"; +import { TestReport } from "@expect/shared/models"; +import { Console, identity, Layer, Stream } from "effect"; +import { Reporter, GitRepoRoot } from "@expect/supervisor"; +import { RrVideo } from "@expect/browser"; +import * as fs from "node:fs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const WEBSITE_OUT_DIR = path.join(__dirname, "../../apps/website/out"); +const CLI_PATH = path.join(__dirname, "../../apps/cli/dist/index.js"); +const TIMEOUT_MS = 900_000; +const TEST_CASE = process.env.TEST_CASE ?? "website"; +const ARTIFACTS_DIR = `/tmp/test-artifacts/${TEST_CASE}`; + +const WEBSITE_INSTRUCTION = `Test the expect.dev marketing website at http://localhost:3000. +Run each item below as a separate test step. If a step fails, record the failure with evidence but continue to the next step. + +1. Homepage loads — navigate to http://localhost:3000, verify the page renders with a hero section and install commands visible. +2. View demo — click the "View demo" button/link on the homepage, verify it navigates to /replay?demo=true and the replay player loads with demo content. +3. Replay controls — on the /replay?demo=true page, verify play/pause button works, speed selector is present, and step list is visible. +4. Copy button — go back to the homepage, click the copy button next to the install command, verify the clipboard contains the expected command text. +5. Theme toggle — click the theme toggle to switch to dark mode, verify the background color changes. Switch back to light mode. +6. Footer links — verify the footer contains links to GitHub (github.com/millionco/expect) and X (x.com/aidenybai) with target="_blank". +7. Legal pages — navigate to /terms, /privacy, and /security in sequence. Verify each page loads with text content. +8. Mobile viewport — resize the viewport to 375x812, navigate to the homepage, verify the page renders without horizontal scrollbar and key content is visible.`; + +const DOGFOOD_INSTRUCTION = `Visit http://localhost:7681 which shows the expect CLI running in a web terminal (xterm.js). \ +This is an interactive terminal UI for a browser testing tool. Test the FULL workflow: \ +(1) Verify the TUI renders with a logo/header and input prompt. \ +(2) Type a test instruction like 'test the homepage at http://localhost:3000' into the input field and submit it. \ +(3) The CLI should generate a test plan — verify the plan review screen appears with test steps. \ +(4) Approve the plan (press Enter or the confirm key). \ +(5) Watch the execution progress — verify steps are being executed with status updates. \ +(6) Wait for completion and verify the results screen shows pass/fail outcomes. \ +Note: This is a terminal rendered in xterm.js. Type by clicking the terminal and using keyboard input.`; + +const TEST_INSTRUCTION = + TEST_CASE === "dogfood" ? DOGFOOD_INSTRUCTION : WEBSITE_INSTRUCTION; + +const layerServer = Layer.effectDiscard( + Effect.acquireRelease( + Effect.promise(() => + import("serve-handler").then(({ default: handler }) => { + const server = createServer((req, res) => + handler(req, res, { public: WEBSITE_OUT_DIR }) + ); + return new Promise((resolve) => + server.listen(3000, () => resolve(server)) + ); + }) + ), + (server) => + Effect.promise( + () => new Promise((resolve) => server.close(() => resolve())) + ) + ) +); + +const main = Effect.gen(function* () { + const reporter = yield* Reporter; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const stdout = yield* ChildProcess.make("node", [ + CLI_PATH, + "--ci", + "--verbose", + "--reporter", + "json", + "--timeout", + String(TIMEOUT_MS), + "--test-id", + TEST_CASE, + "-m", + TEST_INSTRUCTION, + ]).pipe( + spawner.streamString, + Stream.tap((line) => Console.log(line)), + Stream.runCollect, + Effect.map((lines) => lines.join("\n")) + ); + + const report = yield* Schema.decodeEffect(TestReport.json)(stdout); + + const resultsDir = "/tmp/expect-results"; + fs.mkdirSync(resultsDir, { recursive: true }); + fs.writeFileSync(path.join(resultsDir, `${TEST_CASE}.json`), stdout); + + console.log(`\nTest Report: ${report.status}`); + console.log(`Title: ${report.title}`); + console.log(`Summary: ${report.summary}`); + console.log(`Steps: ${report.steps.length}`); + for (const step of report.steps) { + const icon = + step.status === "passed" ? "✓" : step.status === "failed" ? "✗" : "⏭"; + console.log(` ${icon} ${step.title} (${step.status})`); + } + + console.log("\nAssertions:"); + assert.ok( + report.status === "passed" || report.status === "failed", + "status is passed or failed" + ); + assert.ok(report.title.length > 0, "title is non-empty"); + assert.ok(report.summary.length > 0, "summary is non-empty"); + assert.ok(report.steps.length > 0, "has at least one step"); + + fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); + yield* reporter.exportVideo(report, { + exportPathOverride: path.join(ARTIFACTS_DIR, `${TEST_CASE}.mp4`), + }); + + yield* report.assertSuccess(); +}).pipe( + Effect.provide(Reporter.layer), + Effect.provide(RrVideo.layer), + Effect.provide(Layer.succeed(GitRepoRoot, process.cwd())), + Effect.provide(NodeServices.layer), + TEST_CASE === "website" ? Effect.provide(layerServer) : identity +); + +NodeRuntime.runMain(main); diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f0b867417..c5a82f84d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,6 +33,8 @@ jobs: GITHUB_ACTIONS: "1" - name: Build browser runtime run: pnpm --filter @expect/browser run build + - name: Build website + run: pnpm --filter @expect/website run build - name: Install Playwright browsers run: npx playwright install --with-deps chromium webkit firefox - name: Run E2E tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..35657403b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,322 @@ +name: Test + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + # ---- SHARED SETUP ---- + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Build all packages + run: pnpm build + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium webkit firefox + + - name: Install Claude Code + run: pnpm add -g @anthropic-ai/claude-code + + - name: Smoke test Claude Code CLI + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + claude --version + echo "What is 1+1?" | claude -p --max-turns 1 + + - name: Install ttyd + run: sudo apt-get update && sudo apt-get install -y ttyd + + # ---- WEBSITE TEST ---- + - name: Run website test + id: website + continue-on-error: true + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + TEST_CASE: website + run: pnpm tsx .github/scripts/e2e.ts + + - name: Cleanup website state + if: always() + run: rm -rf .expect/ + + # ---- DOGFOOD TEST (PR / dispatch only) ---- + - name: Start CLI in web terminal + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + fuser -k 7681/tcp 2>/dev/null || true + sleep 1 + ttyd -p 7681 --writable node apps/cli/dist/index.js > /tmp/ttyd.log 2>&1 & + TTYD_PID=$! + sleep 3 + if ! kill -0 $TTYD_PID 2>/dev/null; then + echo "::error::ttyd crashed on startup:" + cat /tmp/ttyd.log + exit 1 + fi + curl -sf http://localhost:7681 > /dev/null || { + echo "::error::ttyd failed to start. Logs:" + cat /tmp/ttyd.log + exit 1 + } + + - name: Run dogfood test + id: dogfood + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + continue-on-error: true + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_ACTIONS: "" + TEST_CASE: dogfood + run: pnpm tsx .github/scripts/e2e.ts + + - name: Cleanup dogfood state + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') + run: rm -rf .expect/ + + # ---- ARTIFACTS ---- + - name: Upload session recordings + id: recording + if: always() + uses: actions/upload-artifact@v4 + with: + name: session-recordings + path: /tmp/test-artifacts/ + if-no-files-found: ignore + retention-days: 14 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: /tmp/expect-results/ + if-no-files-found: ignore + retention-days: 14 + + # ---- VIDEO UPLOAD ---- + - name: Upload video to release + id: video + if: always() && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VIDEO_FILE=$(find /tmp/test-artifacts -name "*.mp4" | head -1) + if [ -z "$VIDEO_FILE" ]; then + VIDEO_FILE=$(find /tmp/test-artifacts -name "*.webm" | head -1) + fi + + if [ -z "$VIDEO_FILE" ]; then + echo "No video file found" + exit 0 + fi + + TAG="ci-pr-${{ github.event.pull_request.number }}" + FILENAME=$(basename "$VIDEO_FILE") + + gh release delete "$TAG" --yes --cleanup-tag 2>/dev/null || true + + gh release create "$TAG" \ + --title "CI Recording — PR #${{ github.event.pull_request.number }}" \ + --notes "Session recording from CI. Auto-deleted when the PR is closed." \ + --prerelease \ + "$VIDEO_FILE" + + echo "url=https://github.com/${{ github.repository }}/releases/download/${TAG}/${FILENAME}" >> "$GITHUB_OUTPUT" + + # ---- PR COMMENT ---- + - name: Comment on PR + if: always() && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + ARTIFACT_ID="${{ steps.recording.outputs.artifact-id }}" + VIDEO_URL="${{ steps.video.outputs.url }}" + COMMENT_MARKER="" + + build_scenario_section() { + local JSON_FILE="$1" + local LABEL="$2" + local OUTCOME="$3" + + if [ ! -f "$JSON_FILE" ] || ! jq -e . "$JSON_FILE" > /dev/null 2>&1; then + if [ "$OUTCOME" = "success" ]; then + echo "### ⚠️ ${LABEL}: completed but no valid JSON output" + else + echo "### ❌ ${LABEL}: failed" + fi + return + fi + + local STATUS=$(jq -r '.status' "$JSON_FILE") + local SUMMARY=$(jq -r '.summary // empty' "$JSON_FILE") + + local STATUS_ICON="❌" + if [ "$STATUS" = "passed" ]; then STATUS_ICON="✅"; fi + + local SECTION="### ${STATUS_ICON} ${LABEL}: ${STATUS}" + if [ -n "$SUMMARY" ]; then + SECTION+=$'\n\n'"${SUMMARY}" + fi + SECTION+=$'\n\n'"| Step | Status |" + SECTION+=$'\n'"| --- | --- |" + + local STEP_COUNT=$(jq '.steps | length' "$JSON_FILE") + for i in $(seq 0 $((STEP_COUNT - 1))); do + local STEP_TITLE=$(jq -r ".steps[$i].title" "$JSON_FILE") + local STEP_STATUS=$(jq -r ".steps[$i].status" "$JSON_FILE") + case "$STEP_STATUS" in + passed) local STEP_ICON="✅" ;; + failed) local STEP_ICON="❌" ;; + skipped) local STEP_ICON="⏭️" ;; + *) local STEP_ICON="⬜" ;; + esac + SECTION+=$'\n'"| ${STEP_TITLE} | ${STEP_ICON} ${STEP_STATUS} |" + done + + echo "$SECTION" + } + + BODY="${COMMENT_MARKER}" + BODY+=$'\n'"## Test Results" + + WEBSITE_SECTION=$(build_scenario_section /tmp/expect-results/website.json "Website Test" "${{ steps.website.outcome }}") + BODY+=$'\n\n'"${WEBSITE_SECTION}" + + if [ "${{ steps.dogfood.outcome }}" != "" ] && [ "${{ steps.dogfood.outcome }}" != "skipped" ]; then + DOGFOOD_SECTION=$(build_scenario_section /tmp/expect-results/dogfood.json "Dogfood Test" "${{ steps.dogfood.outcome }}") + BODY+=$'\n\n'"${DOGFOOD_SECTION}" + fi + + if [ -n "$VIDEO_URL" ]; then + BODY+=$'\n\n'"### Session Recording" + BODY+=$'\n\n'"${VIDEO_URL}" + fi + + BODY+=$'\n\n'"---" + BODY+=$'\n'"[Workflow run #${{ github.run_number }}](${RUN_URL})" + + if [ -n "$ARTIFACT_ID" ]; then + BODY+=" | 📎 [Download all recordings](${RUN_URL}/artifacts/${ARTIFACT_ID})" + fi + + EXISTING_COMMENT=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ + --jq ".[] | select(.body | contains(\"${COMMENT_MARKER}\")) | .id" | head -1) + + if [ -n "$EXISTING_COMMENT" ]; then + gh api "repos/${{ github.repository }}/issues/comments/${EXISTING_COMMENT}" \ + -X PATCH -f body="$BODY" + else + gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" + fi + + # ---- COMMIT COMMENT (push only) ---- + - name: Comment on commit + if: always() && github.event_name == 'push' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "${{ steps.website.outcome }}" = "success" ]; then + STATUS="✅ Expect tests passed" + else + STATUS="❌ Expect tests failed" + fi + + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + BODY="${STATUS}" + BODY+=$'\n\n'"[Workflow run #${{ github.run_number }}](${RUN_URL})" + + ARTIFACT_ID="${{ steps.recording.outputs.artifact-id }}" + if [ -n "$ARTIFACT_ID" ]; then + BODY+=$'\n\n'"📎 [Download session recording](${RUN_URL}/artifacts/${ARTIFACT_ID})" + fi + + gh api "repos/${{ github.repository }}/commits/${{ github.sha }}/comments" \ + -f body="$BODY" + + # ---- DEBUG LOGS ---- + - name: Print ttyd logs + if: always() + run: | + echo "=== ttyd logs ===" + if [ -f /tmp/ttyd.log ]; then cat /tmp/ttyd.log; else echo "No ttyd logs"; fi + + - name: Print expect debug logs + if: always() + run: | + if [ -f .expect/logs.md ]; then + tail -n 240 .expect/logs.md + else + echo "No .expect/logs.md file found" + fi + + - name: Print stderr + if: always() + run: | + for f in /tmp/expect-results/*.stderr.txt; do + if [ -f "$f" ]; then + echo "=== $(basename "$f") ===" + cat "$f" + fi + done + + # ---- FAIL CHECK ---- + - name: Fail if tests failed + if: always() + run: | + FAILED=0 + if [ "${{ steps.website.outcome }}" = "failure" ]; then + echo "::error::Website test failed" + FAILED=1 + fi + if [ "${{ steps.dogfood.outcome }}" = "failure" ]; then + echo "::error::Dogfood test failed" + FAILED=1 + fi + if [ "$FAILED" = "1" ]; then + exit 1 + fi + + # ---- CLEANUP OLD CI RELEASES ---- + - name: Cleanup old CI releases + if: always() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release list --json tagName,createdAt --limit 50 2>/dev/null | \ + jq -r '.[] | select(.tagName | startswith("ci-pr-")) | .tagName' | \ + while read -r TAG; do + PR_NUM="${TAG#ci-pr-}" + PR_STATE=$(gh pr view "$PR_NUM" --json state --jq '.state' 2>/dev/null || echo "UNKNOWN") + if [ "$PR_STATE" = "MERGED" ] || [ "$PR_STATE" = "CLOSED" ]; then + gh release delete "$TAG" --yes --cleanup-tag 2>/dev/null || true + fi + done diff --git a/.gitignore b/.gitignore index 90e121af7..f3b9b01c8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ dist tsup.config.bundled_* **/src/generated/ .testie-agent-traces/ -.cursor/plans \ No newline at end of file +packages/video/build +.cursor/plans diff --git a/.gitmodules b/.gitmodules index b889f57ed..e69de29bb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule ".repos/effect"] - path = .repos/effect - url = https://github.com/Effect-TS/effect-smol.git diff --git a/.repos/effect b/.repos/effect index 66a0494ed..54769ed9a 160000 --- a/.repos/effect +++ b/.repos/effect @@ -1 +1 @@ -Subproject commit 66a0494ed75cd12f2721dcbb1d8a072e3d9e14b6 +Subproject commit 54769ed9aa8ee513bbdc4a15d51e2e4042e67394 diff --git a/EFFECT_CHECKLIST.md b/EFFECT_CHECKLIST.md new file mode 100644 index 000000000..2e5dba0f4 --- /dev/null +++ b/EFFECT_CHECKLIST.md @@ -0,0 +1,9 @@ + + +1. does your changes introduce new schemas / types? -> are you 100% SURE there are NO EXISTING SCHEMAS that cover what you're trying to do? + +2. does your code have `Effect.catch`, `Effect.catchTag("UnrecoverableError", ...)`? you should ALMOST ALWAYS let errors bubble up. + - if your error is a fatal error (it cant be recovered from) -> `Effect.catchTag("UnrecoverableError", Effect.die)` + - if your error is a recoverable error -> let it bubble up. + +3. is ALL YOUR EFFECT CODE inside a `ServiceMap`? if not -> put it inside a `ServiceMap`. design your code with a service first approach, first design the service, then implement it, don't just write one function at a time as you explore the problem space diff --git a/apps/cli/package.json b/apps/cli/package.json index a1ce416fc..ecdceb207 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -37,7 +37,8 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - } + }, + "./browser-mcp": "./dist/browser-mcp.js" }, "scripts": { "build": "vp pack", @@ -92,8 +93,10 @@ "@types/node-notifier": "^8.0.5", "@types/prompts": "^2.4.9", "@types/react": "^19.2.14", + "@types/serve-handler": "^6.1.4", "babel-plugin-react-compiler": "^1.0.0", "expect-sdk": "workspace:*", + "serve-handler": "^6.1.7", "typescript": "^5.7.0" }, "optionalDependencies": { diff --git a/apps/cli/src/artifact-server.ts b/apps/cli/src/artifact-server.ts new file mode 100644 index 000000000..1bc3eaee2 --- /dev/null +++ b/apps/cli/src/artifact-server.ts @@ -0,0 +1,129 @@ +import { createServer, request as httpRequest } from "node:http"; +import type { IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; +import { NodeHttpServer, NodeServices } from "@effect/platform-node"; +import { Effect, Layer } from "effect"; +import { Hono } from "hono"; +import { proxy } from "hono/proxy"; +import { serve } from "@hono/node-server"; +import { HttpRouter } from "effect/unstable/http"; +import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { ArtifactRpcs } from "@expect/shared/rpcs"; +import { LIVE_VIEWER_RPC_PORT, LIVE_VIEWER_STATIC_PORT } from "@expect/shared"; +import { CurrentPlanId } from "@expect/shared/models"; +import { ArtifactRpcsLive } from "@expect/supervisor"; +import { ReplayHost } from "./replay-host"; + +const RpcLive = RpcServer.layerHttp({ + group: ArtifactRpcs, + path: "/rpc", +}).pipe(Layer.provide(ArtifactRpcsLive)); + +export const layerArtifactRpcServer = RpcLive.pipe( + Layer.provideMerge(HttpRouter.serve(RpcLive, { disableListenLog: true, disableLogger: true })), + Layer.provide(NodeHttpServer.layer(() => createServer(), { port: LIVE_VIEWER_RPC_PORT })), + Layer.provide(RpcSerialization.layerNdjson), + Layer.provide(NodeServices.layer), + Layer.provide(HttpRouter.layer), +); + +const proxyWebSocketUpgrade = ( + request: IncomingMessage, + socket: Duplex, + head: Buffer, + upstreamOrigin: string, +) => { + const upstreamUrl = new URL(request.url ?? "/", upstreamOrigin); + + const upstreamReq = httpRequest({ + hostname: upstreamUrl.hostname, + port: upstreamUrl.port, + path: upstreamUrl.pathname + upstreamUrl.search, + method: "GET", + headers: { ...request.headers, host: upstreamUrl.host }, + }); + + upstreamReq.on("upgrade", (_res, upstreamSocket, upstreamHead) => { + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${_res.headers["sec-websocket-accept"]}`, + "", + "", + ].join("\r\n"), + ); + + if (upstreamHead.length > 0) socket.write(upstreamHead); + if (head.length > 0) upstreamSocket.write(head); + + upstreamSocket.pipe(socket); + socket.pipe(upstreamSocket); + + socket.on("error", () => upstreamSocket.destroy()); + upstreamSocket.on("error", () => socket.destroy()); + }); + + upstreamReq.on("error", () => socket.destroy()); + upstreamReq.end(); +}; + +export const layerArtifactViewerProxy = Layer.effectDiscard( + Effect.gen(function* () { + const replayHost = yield* ReplayHost; + const planId = yield* CurrentPlanId; + + const normalizedReplayHost = /^https?:\/\//.test(replayHost) + ? replayHost + : `http://${replayHost}`; + const replayHostParsed = new URL(normalizedReplayHost); + + const app = new Hono(); + + app.all("/*", async (context) => { + const upstreamUrl = new URL(context.req.path, normalizedReplayHost); + upstreamUrl.search = new URL(context.req.url).search; + + try { + return await proxy(upstreamUrl.toString(), { + headers: { + "User-Agent": context.req.header("user-agent") ?? "", + Accept: context.req.header("accept") ?? "*/*", + Host: replayHostParsed.host, + }, + }); + } catch { + return context.text(`Bad Gateway: could not reach ${replayHostParsed.host}`, 502); + } + }); + + app.onError((_error, context) => context.text("Internal Server Error", 500)); + + yield* Effect.acquireRelease( + Effect.sync(() => { + const server = serve({ + fetch: app.fetch, + port: LIVE_VIEWER_STATIC_PORT, + }); + server.on("upgrade", (request, socket, head) => { + proxyWebSocketUpgrade(request, socket, head, normalizedReplayHost); + }); + return server; + }), + (server) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), + ); + + yield* Effect.logInfo("Replay proxy started", { + proxyUrl: `http://localhost:${LIVE_VIEWER_STATIC_PORT}`, + replayHost: normalizedReplayHost, + }); + + yield* Effect.logInfo( + `Live viewer: http://localhost:${LIVE_VIEWER_STATIC_PORT}/replay/?testId=${planId}`, + ); + }), +); diff --git a/apps/cli/src/commands/add-github-action.ts b/apps/cli/src/commands/add-github-action.ts index 6a0854602..ff0c0cd3a 100644 --- a/apps/cli/src/commands/add-github-action.ts +++ b/apps/cli/src/commands/add-github-action.ts @@ -1,5 +1,5 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect } from "effect"; import { detectAvailableAgents, type SupportedAgent } from "@expect/agent"; @@ -200,10 +200,10 @@ export const runAddGithubAction = async (options: AddGithubActionOptions = {}) = devUrl = responses.devUrl || devUrl; } - const workflowDir = join(process.cwd(), ".github", "workflows"); - const workflowPath = join(workflowDir, "expect.yml"); + const workflowDir = path.join(process.cwd(), ".github", "workflows"); + const workflowPath = path.join(workflowDir, "expect.yml"); - if (existsSync(workflowPath)) { + if (fs.existsSync(workflowPath)) { if (!nonInteractive) { const response = await prompts({ type: "confirm", @@ -222,8 +222,8 @@ export const runAddGithubAction = async (options: AddGithubActionOptions = {}) = } const workflow = generateWorkflow(packageManager, devCommand, devUrl); - mkdirSync(workflowDir, { recursive: true }); - writeFileSync(workflowPath, workflow); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync(workflowPath, workflow); logger.break(); logger.success("Created .github/workflows/expect.yml"); diff --git a/apps/cli/src/commands/add-skill.ts b/apps/cli/src/commands/add-skill.ts index dd486de0e..01471b106 100644 --- a/apps/cli/src/commands/add-skill.ts +++ b/apps/cli/src/commands/add-skill.ts @@ -1,13 +1,5 @@ -import { - existsSync, - lstatSync, - mkdirSync, - readlinkSync, - symlinkSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import { dirname, join, relative } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { gunzipSync } from "node:zlib"; import { type SupportedAgent, toDisplayName, toSkillDir } from "@expect/agent"; import { highlighter } from "../utils/highlighter"; @@ -65,9 +57,9 @@ export const extractTarEntries = (tar: Buffer, prefix: string, destDir: string) if (name.startsWith(prefix) && isRegularFile) { const relativePath = name.slice(prefix.length); if (relativePath) { - const destPath = join(destDir, relativePath); - mkdirSync(dirname(destPath), { recursive: true }); - writeFileSync(destPath, tar.subarray(offset, offset + size)); + const destPath = path.join(destDir, relativePath); + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.writeFileSync(destPath, tar.subarray(offset, offset + size)); } } @@ -83,7 +75,7 @@ const downloadSkill = async (skillDir: string): Promise => { const compressed = Buffer.from(await response.arrayBuffer()); const tar = gunzipSync(compressed); - mkdirSync(skillDir, { recursive: true }); + fs.mkdirSync(skillDir, { recursive: true }); extractTarEntries(tar, SKILL_ARCHIVE_PREFIX, skillDir); return true; } catch { @@ -115,21 +107,21 @@ const selectAgents = async (agents: readonly SupportedAgent[], nonInteractive: b }; const ensureAgentSymlink = (projectRoot: string, agent: SupportedAgent): boolean | string => { - const skillSourceDir = join(projectRoot, AGENTS_SKILLS_DIR, SKILL_NAME); - const agentSkillDir = join(projectRoot, toSkillDir(agent)); - const symlinkPath = join(agentSkillDir, SKILL_NAME); - const targetPath = relative(dirname(symlinkPath), skillSourceDir); + const skillSourceDir = path.join(projectRoot, AGENTS_SKILLS_DIR, SKILL_NAME); + const agentSkillDir = path.join(projectRoot, toSkillDir(agent)); + const symlinkPath = path.join(agentSkillDir, SKILL_NAME); + const targetPath = path.relative(path.dirname(symlinkPath), skillSourceDir); try { - if (existsSync(symlinkPath)) { - const stats = lstatSync(symlinkPath); + if (fs.existsSync(symlinkPath)) { + const stats = fs.lstatSync(symlinkPath); if (!stats.isSymbolicLink()) return `${symlinkPath} exists and is not a symlink`; - if (readlinkSync(symlinkPath) === targetPath) return true; - unlinkSync(symlinkPath); + if (fs.readlinkSync(symlinkPath) === targetPath) return true; + fs.unlinkSync(symlinkPath); } - mkdirSync(dirname(symlinkPath), { recursive: true }); - symlinkSync(targetPath, symlinkPath); + fs.mkdirSync(path.dirname(symlinkPath), { recursive: true }); + fs.symlinkSync(targetPath, symlinkPath); return true; } catch (error) { const reason = error instanceof Error ? error.message : String(error); @@ -144,7 +136,7 @@ export const runAddSkill = async (options: AddSkillOptions) => { if (selectedAgents.length === 0) return; const skillSpinner = spinner("Downloading skill from GitHub...").start(); - const skillDir = join(projectRoot, AGENTS_SKILLS_DIR, SKILL_NAME); + const skillDir = path.join(projectRoot, AGENTS_SKILLS_DIR, SKILL_NAME); const downloaded = await downloadSkill(skillDir); if (!downloaded) { diff --git a/apps/cli/src/commands/viewer.ts b/apps/cli/src/commands/viewer.ts new file mode 100644 index 000000000..60da6b58a --- /dev/null +++ b/apps/cli/src/commands/viewer.ts @@ -0,0 +1,21 @@ +import { Layer } from "effect"; +import { NodeRuntime } from "@effect/platform-node"; +import { layerCli } from "../layers"; +import type { AgentBackend } from "@expect/agent"; + +interface ViewerOptions { + verbose?: boolean; + agent?: AgentBackend; + replayHost?: string; +} + +export const runViewer = (options: ViewerOptions = {}) => { + console.log("[viewer] options:", options); + const layer = layerCli({ + verbose: options.verbose ?? false, + agent: options.agent ?? "claude", + replayHost: options.replayHost, + }); + + Layer.launch(layer).pipe(NodeRuntime.runMain); +}; diff --git a/apps/cli/src/commands/watch.ts b/apps/cli/src/commands/watch.ts index 64bdc1d99..ba8b2f21f 100644 --- a/apps/cli/src/commands/watch.ts +++ b/apps/cli/src/commands/watch.ts @@ -28,7 +28,6 @@ export const runWatchCommand = async (opts: WatchCommandOpts) => { ...(opts.agent ? { agentBackend: opts.agent } : {}), verbose: opts.verbose ?? false, browserHeaded: opts.headed ?? false, - replayHost: opts.replayHost ?? "https://expect.dev", }); useNavigationStore.setState({ diff --git a/apps/cli/src/components/app.tsx b/apps/cli/src/components/app.tsx index 4cf772e9f..ac514245e 100644 --- a/apps/cli/src/components/app.tsx +++ b/apps/cli/src/components/app.tsx @@ -113,7 +113,7 @@ export const App = ({ agent }: { agent: AgentBackend }) => { changesFor={screen.changesFor} instruction={screen.instruction} savedFlow={screen.savedFlow} - cookieBrowserKeys={screen.cookieBrowserKeys} + cookieImportProfiles={screen.cookieImportProfiles} /> ); case "Testing": @@ -122,7 +122,7 @@ export const App = ({ agent }: { agent: AgentBackend }) => { changesFor={screen.changesFor} instruction={screen.instruction} savedFlow={screen.savedFlow} - cookieBrowserKeys={screen.cookieBrowserKeys} + cookieImportProfiles={screen.cookieImportProfiles} baseUrls={screen.baseUrls} devServerHints={screen.devServerHints} /> @@ -153,7 +153,7 @@ export const App = ({ agent }: { agent: AgentBackend }) => { ); diff --git a/apps/cli/src/components/screens/cookie-sync-confirm-screen.tsx b/apps/cli/src/components/screens/cookie-sync-confirm-screen.tsx index 6a77f29e0..453daf6a0 100644 --- a/apps/cli/src/components/screens/cookie-sync-confirm-screen.tsx +++ b/apps/cli/src/components/screens/cookie-sync-confirm-screen.tsx @@ -1,18 +1,19 @@ import { useEffect, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; +import { Option } from "effect"; import figures from "figures"; import type { ChangesFor, SavedFlow } from "@expect/shared/models"; -import { trackEvent } from "../../utils/session-analytics"; -import { useColors } from "../theme-context"; import { Logo } from "../ui/logo"; import { Spinner } from "../ui/spinner"; +import { useColors } from "../theme-context"; +import { trackEvent } from "../../utils/session-analytics"; import { useNavigationStore, Screen, screenForTestingOrPortPicker, } from "../../stores/use-navigation"; import { useProjectPreferencesStore } from "../../stores/use-project-preferences"; -import { useInstalledBrowsers, type DetectedBrowser } from "../../hooks/use-installed-browsers"; +import { useInstalledBrowsers } from "../../hooks/use-installed-browsers"; interface CookieSyncConfirmScreenProps { changesFor?: ChangesFor; @@ -27,47 +28,54 @@ export const CookieSyncConfirmScreen = ({ }: CookieSyncConfirmScreenProps) => { const COLORS = useColors(); const setScreen = useNavigationStore((state) => state.setScreen); - const setCookieBrowserKeys = useProjectPreferencesStore((state) => state.setCookieBrowserKeys); - const { data: browsers, isLoading } = useInstalledBrowsers(); + const setCookieImportProfiles = useProjectPreferencesStore( + (state) => state.setCookieImportProfiles + ); + const { data, isLoading } = useInstalledBrowsers(); const [highlightedIndex, setHighlightedIndex] = useState(0); - const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState>(new Set()); const defaultsInitialized = useRef(false); - const items: DetectedBrowser[] = browsers ?? []; + const items = data?.browsers ?? []; const itemCount = items.length; useEffect(() => { - if (defaultsInitialized.current || !browsers || browsers.length === 0) return; + if (defaultsInitialized.current || !data || data.browsers.length === 0) + return; defaultsInitialized.current = true; - const defaultBrowser = browsers.find((browser) => browser.isDefault); - if (defaultBrowser) { - setSelectedKeys(new Set([defaultBrowser.key])); + if (Option.isSome(data.default)) { + setSelectedIds(new Set([data.default.value.id])); } - }, [browsers]); + }, [data]); - const toggleKey = (key: string) => { - setSelectedKeys((previous) => { + const toggleId = (id: string) => { + setSelectedIds((previous) => { const next = new Set(previous); - if (next.has(key)) { - next.delete(key); + if (next.has(id)) { + next.delete(id); } else { - next.add(key); + next.add(id); } return next; }); }; const confirm = () => { - const keys = [...selectedKeys]; - setCookieBrowserKeys(keys); - trackEvent("cookies:sync_choice", { - choice: keys.length > 0 ? "use_cookies" : "skip_cookies", + const selectedProfiles = items.filter((browser) => + selectedIds.has(browser.id) + ); + setCookieImportProfiles(selectedProfiles); + trackEvent("cookies:browser_selection", { + selected_count: selectedProfiles.length, + browsers: selectedProfiles + .map((browser) => browser.displayName) + .join(", "), }); - if (keys.length > 0) { + if (selectedProfiles.length > 0) { trackEvent("cookies:browser_selection", { - selected_count: keys.length, - browsers: keys.join(","), + selected_count: selectedProfiles.length, + browsers: selectedProfiles.map((p) => p.id).join(","), }); trackEvent("cookies:toggled", { enabled: true }); } @@ -77,8 +85,8 @@ export const CookieSyncConfirmScreen = ({ changesFor, instruction, savedFlow, - cookieBrowserKeys: keys, - }), + cookieImportProfiles: selectedProfiles, + }) ); } else { setScreen(Screen.Main()); @@ -98,16 +106,15 @@ export const CookieSyncConfirmScreen = ({ if (input === " " && itemCount > 0) { const item = items[highlightedIndex]; - if (item) toggleKey(item.key); + if (item) toggleId(item.id); } if (input === "a") { - const allKeys = items.map((browser) => browser.key); - setSelectedKeys(new Set(allKeys)); + setSelectedIds(new Set(items.map((browser) => browser.id))); } if (input === "n") { - setSelectedKeys(new Set()); + setSelectedIds(new Set()); } if (key.return) { @@ -119,7 +126,7 @@ export const CookieSyncConfirmScreen = ({ } }); - const selectedCount = selectedKeys.size; + const selectedCount = selectedIds.size; return ( @@ -128,20 +135,24 @@ export const CookieSyncConfirmScreen = ({ {" "} {figures.pointerSmall}{" "} - {instruction ?? "Select browsers for cookie sync"} + + {instruction ?? "Select browsers for cookie sync"} + {selectedCount > 0 && ( - {figures.tick} Your signed-in session will be synced from {selectedCount} browser + {figures.tick} Your signed-in session will be synced from{" "} + {selectedCount} browser {selectedCount === 1 ? "" : "s"} )} {selectedCount === 0 && ( - {figures.warning} No browsers selected — tests run without authentication + {figures.warning} No browsers selected — tests run without + authentication )} @@ -155,21 +166,27 @@ export const CookieSyncConfirmScreen = ({ {!isLoading && ( {items.map((browser, index) => { + const id = browser.id; const isHighlighted = index === highlightedIndex; - const isSelected = selectedKeys.has(browser.key); + const isSelected = selectedIds.has(id); + const isDefault = + Option.isSome(data!.default) && data!.default.value.id === id; return ( - + {isHighlighted ? `${figures.pointer} ` : " "} {isSelected ? figures.checkboxOn : figures.checkboxOff}{" "} - + {browser.displayName} - {browser.isDefault && (default)} + {isDefault && (default)} ); })} diff --git a/apps/cli/src/components/screens/main-menu-screen.tsx b/apps/cli/src/components/screens/main-menu-screen.tsx index a748a0683..1476f69a4 100644 --- a/apps/cli/src/components/screens/main-menu-screen.tsx +++ b/apps/cli/src/components/screens/main-menu-screen.tsx @@ -71,9 +71,9 @@ export const MainMenu = ({ gitState }: MainMenuProps) => { const [errorMessage, setErrorMessage] = useState(undefined); const [historyIndex, setHistoryIndex] = useState(-1); const [savedCurrentInput, setSavedCurrentInput] = useState(""); - const cookieBrowserKeys = useProjectPreferencesStore((state) => state.cookieBrowserKeys); - const clearCookieBrowserKeys = useProjectPreferencesStore( - (state) => state.clearCookieBrowserKeys, + const cookieImportProfiles = useProjectPreferencesStore((state) => state.cookieImportProfiles); + const clearCookieImportProfiles = useProjectPreferencesStore( + (state) => state.clearCookieImportProfiles, ); const { data: testCoverage } = useTestCoverage(gitState); @@ -158,13 +158,12 @@ export const MainMenu = ({ gitState }: MainMenuProps) => { usePreferencesStore.getState().rememberInstruction(trimmed); - if (cookieBrowserKeys.length > 0 || containsUrl(trimmed) || cliBaseUrls) { + if (cookieImportProfiles.length > 0 || containsUrl(trimmed)) { setScreen( screenForTestingOrPortPicker({ changesFor, instruction: trimmed, - cookieBrowserKeys, - baseUrls: cliBaseUrls ? [...cliBaseUrls] : undefined, + cookieImportProfiles, }), ); } else { @@ -231,8 +230,8 @@ export const MainMenu = ({ gitState }: MainMenuProps) => { } if (key.ctrl && input === "k") { - if (cookieBrowserKeys.length > 0) { - clearCookieBrowserKeys(); + if (cookieImportProfiles.length > 0) { + clearCookieImportProfiles(); trackEvent("cookies:cleared"); trackEvent("cookies:toggled", { enabled: false }); } else { diff --git a/apps/cli/src/components/screens/port-picker-screen.tsx b/apps/cli/src/components/screens/port-picker-screen.tsx index 0c2980d55..1dbdb7ed8 100644 --- a/apps/cli/src/components/screens/port-picker-screen.tsx +++ b/apps/cli/src/components/screens/port-picker-screen.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Box, Text, useInput } from "ink"; import figures from "figures"; import type { ChangesFor, SavedFlow } from "@expect/shared/models"; +import type { Browser } from "@expect/cookies"; import { PORT_PICKER_VISIBLE_COUNT } from "../../constants"; import { useColors } from "../theme-context"; import { useNavigationStore, Screen, type DevServerHint } from "../../stores/use-navigation"; @@ -18,7 +19,7 @@ interface PortPickerScreenProps { changesFor: ChangesFor; instruction: string; savedFlow?: SavedFlow; - cookieBrowserKeys?: readonly string[]; + cookieImportProfiles?: readonly Browser[]; } interface PortEntry { @@ -72,7 +73,7 @@ export const PortPickerScreen = ({ changesFor, instruction, savedFlow, - cookieBrowserKeys, + cookieImportProfiles, }: PortPickerScreenProps) => { const COLORS = useColors(); const setScreen = useNavigationStore((state) => state.setScreen); @@ -172,7 +173,7 @@ export const PortPickerScreen = ({ changesFor, instruction, savedFlow, - cookieBrowserKeys, + cookieImportProfiles, baseUrls: allUrls.length > 0 ? allUrls : undefined, devServerHints: devServerHints.length > 0 ? devServerHints : undefined, }), diff --git a/apps/cli/src/components/screens/results-screen.tsx b/apps/cli/src/components/screens/results-screen.tsx index 7ca94076d..d2727e35e 100644 --- a/apps/cli/src/components/screens/results-screen.tsx +++ b/apps/cli/src/components/screens/results-screen.tsx @@ -16,7 +16,6 @@ import { useNavigationStore, screenForTestingOrPortPicker } from "../../stores/u import { usePlanExecutionStore } from "../../stores/use-plan-execution-store"; import { saveFlowFn } from "../../data/flow-storage-atom"; import { formatElapsedTime } from "../../utils/format-elapsed-time"; -import { getStepElapsedMs, getTotalElapsedMs } from "../../utils/step-elapsed"; interface ResultsScreenProps { report: TestReport; @@ -99,7 +98,7 @@ export const ResultsScreen = ({ const statusColor = isPassed ? COLORS.GREEN : COLORS.RED; const statusIcon = isPassed ? figures.tick : figures.cross; const statusLabel = isPassed ? "Passed" : "Failed"; - const totalElapsedMs = getTotalElapsedMs(report.steps); + const totalElapsedMs = report.totalDurationMs; const displayedReplayUrl = replayUrl ?? localReplayUrl; const showLocalReplayLine = Boolean(replayUrl) && @@ -135,7 +134,7 @@ export const ResultsScreen = ({ {report.steps.map((step: TestPlanStep, stepIndex: number) => { - const stepElapsedMs = getStepElapsedMs(step); + const stepElapsedMs = step.elapsedMs; const stepElapsedLabel = stepElapsedMs !== undefined ? formatElapsedTime(stepElapsedMs) : undefined; const stepStatus = report.stepStatuses.get(step.id); diff --git a/apps/cli/src/components/screens/saved-flow-picker-screen.tsx b/apps/cli/src/components/screens/saved-flow-picker-screen.tsx index 00eab0e01..b896f3fbd 100644 --- a/apps/cli/src/components/screens/saved-flow-picker-screen.tsx +++ b/apps/cli/src/components/screens/saved-flow-picker-screen.tsx @@ -31,9 +31,7 @@ const selectFlow = (flow: SavedFlowFileData, mainBranch: string) => { userInstruction: flow.flow.userInstruction, steps, }; - const storedKeys = useProjectPreferencesStore.getState().cookieBrowserKeys; - const cookieBrowserKeys = - flow.environment.cookies && storedKeys.length === 0 ? ["chrome"] : storedKeys; + const cookieImportProfiles = useProjectPreferencesStore.getState().cookieImportProfiles; trackEvent("flow:reused", { slug: flow.slug, step_count: steps.length }); @@ -42,7 +40,7 @@ const selectFlow = (flow: SavedFlowFileData, mainBranch: string) => { changesFor, instruction: flow.flow.userInstruction, savedFlow, - cookieBrowserKeys, + cookieImportProfiles, }), ); }; diff --git a/apps/cli/src/components/screens/testing-screen.tsx b/apps/cli/src/components/screens/testing-screen.tsx index 861ce0a31..eda3375c1 100644 --- a/apps/cli/src/components/screens/testing-screen.tsx +++ b/apps/cli/src/components/screens/testing-screen.tsx @@ -26,7 +26,9 @@ import cliTruncate from "cli-truncate"; import { formatElapsedTime } from "../../utils/format-elapsed-time"; import { Image } from "../ui/image"; import { ErrorMessage } from "../ui/error-message"; -import { executeFn, screenshotPathsAtom } from "../../data/execution-atom"; +import { LIVE_VIEWER_STATIC_URL } from "@expect/shared"; +import type { Browser } from "@expect/cookies"; +import { executeAtomFn, screenshotPathsAtom } from "../../data/execution-atom"; import { agentConfigOptionsAtom } from "../../data/config-options"; import { agentProviderAtom } from "../../data/runtime"; import { trackEvent } from "../../utils/session-analytics"; @@ -40,7 +42,7 @@ interface TestingScreenProps { changesFor: ChangesFor; instruction: string; savedFlow?: SavedFlow; - cookieBrowserKeys?: readonly string[]; + cookieImportProfiles?: readonly Browser[]; baseUrls?: readonly string[]; devServerHints?: readonly DevServerHint[]; } @@ -262,7 +264,7 @@ export const TestingScreen = ({ changesFor, instruction, savedFlow, - cookieBrowserKeys = [], + cookieImportProfiles = [], baseUrls, devServerHints, }: TestingScreenProps) => { @@ -280,13 +282,11 @@ export const TestingScreen = ({ (state) => state.modelPreferences[agentBackend]?.value, ); const browserHeaded = usePreferencesStore((state) => state.browserHeaded); - const replayHost = usePreferencesStore((state) => state.replayHost); const toggleNotifications = usePreferencesStore((state) => state.toggleNotifications); - const [executionResult, triggerExecute] = useAtom(executeFn, { + const [executionResult, triggerExecute] = useAtom(executeAtomFn, { mode: "promiseExit", }); const screenshotPaths = useAtomValue(screenshotPathsAtom); - const [liveReplayUrl, setLiveReplayUrl] = useState(undefined); const isExecuting = AsyncResult.isWaiting(executionResult); const isExecutionComplete = AsyncResult.isSuccess(executionResult); @@ -421,7 +421,7 @@ export const TestingScreen = ({ changesFor, instruction, isHeadless: !browserHeaded, - cookieBrowserKeys: [...cookieBrowserKeys], + cookieImportProfiles: [...cookieImportProfiles], savedFlow, baseUrl, devServerHints: devServerHints ? [...devServerHints] : undefined, @@ -431,9 +431,7 @@ export const TestingScreen = ({ : undefined, }, agentBackend, - replayHost, onUpdate: setExecutedPlan, - onReplayUrl: setLiveReplayUrl, onConfigOptions: (configOptions) => { setConfigOptions((previous) => ({ ...previous, @@ -452,25 +450,21 @@ export const TestingScreen = ({ changesFor, instruction, savedFlow, - cookieBrowserKeys, + cookieImportProfiles, baseUrls, - devServerHints, - replayHost, modelPreferenceConfigId, modelPreferenceValue, setConfigOptions, ]); const replayUrl = isExecutionComplete ? executionResult.value.replayUrl : undefined; - const localReplayUrl = isExecutionComplete ? executionResult.value.localReplayUrl : undefined; - const videoUrl = isExecutionComplete ? executionResult.value.videoUrl : undefined; useEffect(() => { if (isExecutionComplete && executedPlan && report) { usePlanExecutionStore.getState().setExecutedPlan(executedPlan); - setScreen(Screen.Results({ report, replayUrl, localReplayUrl, videoUrl })); + setScreen(Screen.Results({ report, replayUrl })); } - }, [isExecutionComplete, executedPlan, report, replayUrl, localReplayUrl, videoUrl, setScreen]); + }, [isExecutionComplete, executedPlan, report, replayUrl, setScreen]); const goToMain = () => { usePlanExecutionStore.getState().setExecutedPlan(undefined); @@ -502,10 +496,10 @@ export const TestingScreen = ({ return; } - if (normalizedInput === "o" && !key.ctrl && !key.meta && liveReplayUrl) { + if (normalizedInput === "o" && !key.ctrl && !key.meta && executedPlan?.id) { const { exec } = require("node:child_process") as typeof import("node:child_process"); - const escapedUrl = liveReplayUrl.replace(/"/g, '\\"'); - exec(`open "${escapedUrl}"`); + const url = `${LIVE_VIEWER_STATIC_URL}/replay/?testId=${executedPlan.id}`; + exec(`open "${url}"`); trackEvent("live_preview:opened"); return; } @@ -540,7 +534,7 @@ export const TestingScreen = ({ } if (executedPlan && report) { usePlanExecutionStore.getState().setExecutedPlan(executedPlan); - setScreen(Screen.Results({ report, replayUrl, localReplayUrl, videoUrl })); + setScreen(Screen.Results({ report, replayUrl })); return; } goToMain(); @@ -572,7 +566,7 @@ export const TestingScreen = ({ - {liveReplayUrl && isExecuting && ( + {executedPlan?.id && isExecuting && ( {" "}Press{" "} diff --git a/apps/cli/src/components/screens/watch-screen.tsx b/apps/cli/src/components/screens/watch-screen.tsx index 5631953bb..bc642887e 100644 --- a/apps/cli/src/components/screens/watch-screen.tsx +++ b/apps/cli/src/components/screens/watch-screen.tsx @@ -3,6 +3,7 @@ import { Box, Text, useInput } from "ink"; import figures from "figures"; import { Effect, Fiber, Option } from "effect"; import type { ChangesFor, ExecutedTestPlan, TestPlanStep } from "@expect/shared/models"; +import type { Browser } from "@expect/cookies"; import type { WatchEvent } from "@expect/supervisor"; import { Watch } from "@expect/supervisor"; import { useMountEffect } from "../../hooks/use-mount-effect"; @@ -28,7 +29,7 @@ import { stripUndefinedRequirement } from "../../utils/strip-undefined-requireme interface WatchScreenProps { changesFor: ChangesFor; instruction: string; - cookieBrowserKeys?: readonly string[]; + cookieImportProfiles?: readonly Browser[]; baseUrl?: string; } @@ -37,7 +38,7 @@ type WatchPhase = "polling" | "settling" | "assessing" | "running" | "idle" | "e export const WatchScreen = ({ changesFor, instruction, - cookieBrowserKeys = [], + cookieImportProfiles = [], baseUrl, }: WatchScreenProps) => { const COLORS = useColors(); @@ -120,7 +121,7 @@ export const WatchScreen = ({ changesFor, instruction, isHeadless: !browserHeaded, - cookieBrowserKeys: [...cookieBrowserKeys], + cookieImportProfiles: [...cookieImportProfiles], baseUrl, onEvent: handleEvent, }); diff --git a/apps/cli/src/components/ui/modeline.tsx b/apps/cli/src/components/ui/modeline.tsx index ffea7c20f..13c1dfbf1 100644 --- a/apps/cli/src/components/ui/modeline.tsx +++ b/apps/cli/src/components/ui/modeline.tsx @@ -17,7 +17,7 @@ import { agentProviderAtom } from "../../data/runtime"; const useHintSegments = (screen: Screen, gitState: GitState | undefined): HintSegment[] => { const COLORS = useColors(); - const cookieBrowserKeys = useProjectPreferencesStore((state) => state.cookieBrowserKeys); + const cookieImportProfiles = useProjectPreferencesStore((state) => state.cookieImportProfiles); const notifications = usePreferencesStore((state) => state.notifications); const expanded = usePlanExecutionStore((state) => state.expanded); const agentProviderValue = useAtomValue(agentProviderAtom); @@ -32,7 +32,9 @@ const useHintSegments = (screen: Screen, gitState: GitState | undefined): HintSe { key: "ctrl+k", label: - cookieBrowserKeys.length > 0 ? `cookies (${cookieBrowserKeys.length})` : "cookies off", + cookieImportProfiles.length > 0 + ? `cookies (${cookieImportProfiles.length})` + : "cookies off", cta: true, }, { key: "ctrl+r", label: "saved flows", cta: true }, diff --git a/apps/cli/src/data/execution-atom.ts b/apps/cli/src/data/execution-atom.ts index 9ead94af4..e79d4083b 100644 --- a/apps/cli/src/data/execution-atom.ts +++ b/apps/cli/src/data/execution-atom.ts @@ -1,29 +1,33 @@ import { Effect, Option, Stream } from "effect"; import * as Atom from "effect/unstable/reactivity/Atom"; -import { ExecutedTestPlan, Executor, Git, Reporter, type ExecuteOptions } from "@expect/supervisor"; +import { + ExecutedTestPlan, + Executor, + Git, + Reporter, + type ExecuteOptions, +} from "@expect/supervisor"; import { Analytics } from "@expect/shared/observability"; +import { LIVE_VIEWER_STATIC_URL } from "@expect/shared"; import type { AgentBackend } from "@expect/agent"; import type { AcpConfigOption, TestReport } from "@expect/shared/models"; import { cliAtomRuntime } from "./runtime"; -import { stripUndefinedRequirement } from "../utils/strip-undefined-requirement"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { startReplayProxy } from "../utils/replay-proxy-server"; -import { toViewerRunState, pushStepState } from "../utils/push-step-state"; -import { extractCloseArtifacts } from "../utils/extract-close-artifacts"; -import { loadReplayEvents } from "../utils/load-replay-events"; -const LIVE_VIEW_PORT_MIN = 50000; -const LIVE_VIEW_PORT_RANGE = 10000; +const REPLAY_REPORT_PREFIX = "rrweb report:"; +const PLAYWRIGHT_VIDEO_PREFIX = "Playwright video:"; -const pickRandomPort = () => LIVE_VIEW_PORT_MIN + Math.floor(Math.random() * LIVE_VIEW_PORT_RANGE); +const artifactViewerUrl = (planId: string) => + `${LIVE_VIEWER_STATIC_URL}/replay/?testId=${planId}`; interface ExecuteInput { readonly options: ExecuteOptions; readonly agentBackend: AgentBackend; - readonly replayHost?: string; readonly onUpdate: (executed: ExecutedTestPlan) => void; readonly onReplayUrl?: (url: string) => void; - readonly onConfigOptions?: (configOptions: readonly AcpConfigOption[]) => void; + readonly onConfigOptions?: ( + configOptions: readonly AcpConfigOption[] + ) => void; + readonly onLiveViewUrl?: (url: string) => void; } export interface ExecutionResult { @@ -37,174 +41,80 @@ export interface ExecutionResult { // HACK: atom is read by testing-screen.tsx but never populated — screenshots are saved via McpSession instead export const screenshotPathsAtom = Atom.make([]); -const syncReplayProxy = Effect.fn("syncReplayProxy")(function* ( - replayUrl: string | undefined, - liveViewUrl: string, - replaySessionPath: string | undefined, - executed: ExecutedTestPlan, -) { - if (!replayUrl) return; - - const proxyBase = replayUrl.split("/replay")[0]; - const replayEvents = yield* loadReplayEvents({ liveViewUrl, replaySessionPath }); - - if (replayEvents && replayEvents.length > 0) { - yield* Effect.tryPromise(() => - fetch(`${proxyBase}/latest.json`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(replayEvents), - }), - ).pipe( - Effect.catchTag("UnknownError", (error) => - Effect.logWarning("Failed to sync replay events to proxy", error), - ), - ); - } - - yield* pushStepState(proxyBase, toViewerRunState(executed)); -}); - -const executeCore = (input: ExecuteInput) => - Effect.gen(function* () { - const reporter = yield* Reporter; - const executor = yield* Executor; - const analytics = yield* Analytics; - const git = yield* Git; - - yield* Effect.logInfo("Execution starting", { - agentBackend: input.agentBackend, - hasReplayHost: Boolean(input.replayHost), - instructionLength: input.options.instruction.length, - changesFor: input.options.changesFor._tag, - }); - - const runStartedAt = Date.now(); - - const liveViewPort = pickRandomPort(); - const liveViewUrl = `http://localhost:${liveViewPort}`; - - let replayUrl: string | undefined; - - if (input.replayHost) { - const proxyHandle = yield* startReplayProxy({ - replayHost: input.replayHost, - liveViewUrl, - }); - replayUrl = `${proxyHandle.url}/replay`; - - yield* Effect.logInfo("Replay viewer available", { replayUrl }); - yield* Effect.sync(() => input.onReplayUrl?.(`${replayUrl}?live=true`)); - } - - const executeOptions: ExecuteOptions = { - ...input.options, - liveViewUrl, - onConfigOptions: input.onConfigOptions, - }; - - yield* analytics.capture("run:started", { plan_id: "direct" }); - - const finalExecuted = yield* executor.execute(executeOptions).pipe( - Stream.tap((executed) => - Effect.gen(function* () { - input.onUpdate(executed); - yield* pushStepState(liveViewUrl, toViewerRunState(executed)); - }), - ), - Stream.runLast, - Effect.map((option) => - (option._tag === "Some" - ? option.value - : new ExecutedTestPlan({ - ...input.options, - id: "" as never, - changesFor: input.options.changesFor, - currentBranch: "", - diffPreview: "", - fileStats: [], - instruction: input.options.instruction, - baseUrl: undefined as never, - isHeadless: input.options.isHeadless, - cookieBrowserKeys: input.options.cookieBrowserKeys, - testCoverage: Option.none(), - title: input.options.instruction, - rationale: "Direct execution", - steps: [], - events: [], - }) +export const executeAtomFn = cliAtomRuntime.fn( + Effect.fnUntraced( + function* (input: ExecuteInput, _ctx: Atom.FnContext) { + const reporter = yield* Reporter; + const executor = yield* Executor; + const analytics = yield* Analytics; + const git = yield* Git; + + const runStartedAt = Date.now(); + + yield* analytics.capture("run:started", { plan_id: "direct" }); + + const finalExecuted = yield* executor.execute(input.options).pipe( + Stream.tap((executed) => + Effect.sync(() => { + input.onUpdate(executed); + }) + ), + Stream.runLast, + Effect.map((option) => + (option._tag === "Some" + ? option.value + : new ExecutedTestPlan({ + ...input.options, + id: "" as never, + changesFor: input.options.changesFor, + currentBranch: "", + diffPreview: "", + fileStats: [], + instruction: input.options.instruction, + baseUrl: undefined as never, + isHeadless: input.options.isHeadless, + cookieImportProfiles: input.options.cookieImportProfiles, + testCoverage: Option.none(), + title: input.options.instruction, + rationale: "Direct execution", + steps: [], + events: [], + }) + ) + .finalizeTextBlock() + .synthesizeRunFinished() ) - .finalizeTextBlock() - .synthesizeRunFinished(), - ), - ); - - const artifacts = extractCloseArtifacts(finalExecuted.events); - - yield* syncReplayProxy(replayUrl, liveViewUrl, artifacts.replaySessionPath, finalExecuted); - - const report = yield* reporter.report(finalExecuted); - - const passedCount = report.steps.filter( - (step) => report.stepStatuses.get(step.id)?.status === "passed", - ).length; - const failedCount = report.steps.filter( - (step) => report.stepStatuses.get(step.id)?.status === "failed", - ).length; - - const durationMs = Date.now() - runStartedAt; - - yield* Effect.logInfo("Execution completed", { - status: report.status, - passedCount, - failedCount, - stepCount: finalExecuted.steps.length, - durationMs, - }); - - yield* analytics.capture("run:completed", { - plan_id: finalExecuted.id ?? "direct", - passed: passedCount, - failed: failedCount, - step_count: finalExecuted.steps.length, - file_count: 0, - duration_ms: durationMs, - }); - - if (report.status === "passed") { - yield* git.saveTestedFingerprint(); - } - - return { - executedPlan: finalExecuted, - report, - replayUrl: replayUrl ?? artifacts.localReplayUrl, - localReplayUrl: artifacts.localReplayUrl, - videoUrl: artifacts.videoUrl, - } satisfies ExecutionResult; - }).pipe(Effect.withSpan("expect.session")); + ); + + const report = yield* reporter.report(finalExecuted); + + const passedCount = report.steps.filter( + (step) => report.stepStatuses.get(step.id)?.status === "passed" + ).length; + const failedCount = report.steps.filter( + (step) => report.stepStatuses.get(step.id)?.status === "failed" + ).length; + + yield* analytics.capture("run:completed", { + plan_id: finalExecuted.id ?? "direct", + passed: passedCount, + failed: failedCount, + step_count: finalExecuted.steps.length, + file_count: 0, + duration_ms: Date.now() - runStartedAt, + }); -export const executeFn = cliAtomRuntime.fn()((input) => - stripUndefinedRequirement(executeCore(input).pipe(Effect.annotateLogs({ fn: "executeFn" }))).pipe( - Effect.tapError((error) => - Effect.gen(function* () { - const analytics = yield* Analytics; - const errorTag = - typeof error === "object" && - error !== null && - "_tag" in error && - typeof error._tag === "string" - ? error._tag - : // ignore for now - (error as any) instanceof Error - ? (error as any).constructor.name - : "UnknownError"; - yield* analytics.capture("run:failed", { - plan_id: "direct", - error_tag: errorTag, - }); - }).pipe(Effect.catchCause(() => Effect.void)), - ), - Effect.provide(NodeServices.layer), - ), + if (report.status === "passed") { + yield* git.saveTestedFingerprint(); + } + + return { + executedPlan: finalExecuted, + report, + replayUrl: artifactViewerUrl(finalExecuted.id), + } satisfies ExecutionResult; + }, + Effect.annotateLogs({ fn: "executeAtomFn" }), + Effect.withSpan("expect.session") + ) ); diff --git a/apps/cli/src/hooks/use-config-options.ts b/apps/cli/src/hooks/use-config-options.ts index ae6e3421c..b17a94836 100644 --- a/apps/cli/src/hooks/use-config-options.ts +++ b/apps/cli/src/hooks/use-config-options.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { Effect } from "effect"; import { Agent, type AgentBackend } from "@expect/agent"; import type { AcpConfigOption } from "@expect/shared/models"; -import * as NodeServices from "@effect/platform-node/NodeServices"; +import { layerCli } from "../layers"; export const useConfigOptions = (agent: AgentBackend | undefined) => useQuery({ @@ -15,8 +15,8 @@ export const useConfigOptions = (agent: AgentBackend | undefined) => const agentService = yield* Agent; return yield* agentService.fetchConfigOptions(process.cwd()); }).pipe( - Effect.provide(Agent.layerFor(agent)), - Effect.provide(NodeServices.layer), + Effect.provide(layerCli({ verbose: false, agent })), + Effect.scoped, Effect.catchTags({ AcpSessionCreateError: () => Effect.succeed([] as readonly AcpConfigOption[]), AcpProviderUnauthenticatedError: () => Effect.succeed([] as readonly AcpConfigOption[]), diff --git a/apps/cli/src/hooks/use-installed-browsers.ts b/apps/cli/src/hooks/use-installed-browsers.ts index f0e9a0868..4e9d6f937 100644 --- a/apps/cli/src/hooks/use-installed-browsers.ts +++ b/apps/cli/src/hooks/use-installed-browsers.ts @@ -1,48 +1,16 @@ import { useQuery } from "@tanstack/react-query"; import { Effect, Option } from "effect"; -import { Browsers, layerLive, browserKeyOf, browserDisplayName } from "@expect/cookies"; -import type { BrowserKey } from "@expect/cookies"; +import { Browsers, layerLive, type Browser } from "@expect/cookies"; import * as NodeServices from "@effect/platform-node/NodeServices"; -export interface DetectedBrowser { - key: BrowserKey; - displayName: string; - isDefault: boolean; -} - export const useInstalledBrowsers = () => useQuery({ queryKey: ["installed-browsers"], - queryFn: (): Promise => + queryFn: (): Promise<{ default: Option.Option; browsers: Browser[] }> => Effect.gen(function* () { - const browsers = yield* Browsers; - const allBrowsers = yield* browsers.list.pipe( - Effect.catchTag("ListBrowsersError", () => Effect.succeed([])), - ); - const maybeDefault = yield* browsers.defaultBrowser().pipe( - Effect.map(Option.map(browserKeyOf)), - Effect.map(Option.getOrUndefined), - Effect.catchTag("ListBrowsersError", () => Effect.succeed(undefined)), - ); - - const seen = new Set(); - const result: DetectedBrowser[] = []; - for (const browser of allBrowsers) { - const key = browserKeyOf(browser); - if (seen.has(key)) continue; - seen.add(key); - result.push({ - key, - displayName: browserDisplayName(browser), - isDefault: key === maybeDefault, - }); - } - return result; - }).pipe( - Effect.provide(layerLive), - Effect.provide(NodeServices.layer), - Effect.tapCause((cause) => Effect.logWarning("Browser detection failed", { cause })), - Effect.catchCause(() => Effect.succeed([] as DetectedBrowser[])), - Effect.runPromise, - ), + const browsersService = yield* Browsers; + const browsers = yield* browsersService.list; + const defaultBrowser = yield* browsersService.defaultBrowser(); + return { default: defaultBrowser, browsers }; + }).pipe(Effect.provide(layerLive), Effect.provide(NodeServices.layer), Effect.runPromise), }); diff --git a/apps/cli/src/index.tsx b/apps/cli/src/index.tsx index 0ec45a742..a7d850b97 100644 --- a/apps/cli/src/index.tsx +++ b/apps/cli/src/index.tsx @@ -1,14 +1,17 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { Option } from "effect"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Effect, Option } from "effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { Command } from "commander"; import { ChangesFor } from "@expect/supervisor"; +import { Browsers, layerLive } from "@expect/cookies"; import { runHeadless } from "./utils/run-test"; import { runInit } from "./commands/init"; import { runAddGithubAction } from "./commands/add-github-action"; import { runAddSkill } from "./commands/add-skill"; import { runAuditCommand } from "./commands/audit"; import { runWatchCommand } from "./commands/watch"; +import { runViewer } from "./commands/viewer"; import { isRunningInAgent } from "@expect/shared/launched-from"; import { isHeadless } from "./utils/is-headless"; import { type AgentBackend, detectAvailableAgents } from "@expect/agent"; @@ -20,6 +23,7 @@ import { CI_EXECUTION_TIMEOUT_MS, VERSION, VERSION_API_URL } from "./constants"; import { prompts } from "./utils/prompts"; import { highlighter } from "./utils/highlighter"; import { logger } from "./utils/logger"; +import { DEFAULT_REPLAY_HOST } from "@expect/shared"; try { fetch(`${VERSION_API_URL}?source=cli&t=${Date.now()}`).catch(() => {}); @@ -43,11 +47,15 @@ interface CommanderOpts { verbose?: boolean; headed?: boolean; noCookies?: boolean; + browserProfile?: string[]; replayHost?: string; ci?: boolean; + tui?: boolean; timeout?: number; output?: OutputFormat; url?: string[]; + reporter?: "json" | "github-actions"; + testId?: string; } // HACK: when adding or changing options/commands below, update the Options and Commands tables in README-new.md @@ -66,11 +74,26 @@ const program = new Command() .option("--verbose", "enable verbose logging") .option("--headed", "show a visible browser window during tests") .option("--no-cookies", "skip system browser cookie extraction") + .option( + "-b, --browser-profile ", + "browser profile(s) to import cookies from (run `expect list-browsers` to see available profiles)", + ) .option("--ci", "force CI mode: headless, no cookies, auto-yes, 30-minute timeout") + .option( + "--tui ", + "enable or disable the interactive TUI", + (value) => value !== "false", + true, + ) .option("--timeout ", "execution timeout in milliseconds", parseInt) .option("--output ", "output format: text (default) or json") .option("-u, --url ", "base URL(s) for the dev server (skips port picker)") - .option("--replay-host ", "website host for live replay viewer", "https://expect.dev") + .option("--reporter ", "reporter: json, github-actions") + .option( + "--test-id ", + "override the test plan ID — determines the path for test data, e.g. .expect/artifacts/.ndjson", + ) + .option("--replay-host ", "website host for live replay viewer", DEFAULT_REPLAY_HOST) .addHelpText( "after", ` @@ -89,12 +112,15 @@ const seedStores = (opts: CommanderOpts, changesFor: ChangesFor) => { usePreferencesStore.setState({ verbose: opts.verbose ?? false, browserHeaded: opts.headed ?? false, - replayHost: opts.replayHost ?? "https://expect.dev", }); if (opts.message) { useNavigationStore.setState({ - screen: Screen.Testing({ changesFor, instruction: opts.message, baseUrls: opts.url }), + screen: Screen.Testing({ + changesFor, + instruction: opts.message, + baseUrls: opts.url, + }), }); } else { useNavigationStore.setState({ screen: Screen.Main() }); @@ -105,8 +131,14 @@ const seedStores = (opts: CommanderOpts, changesFor: ChangesFor) => { } }; +const shouldRunHeadless = (opts: CommanderOpts): boolean => { + if (opts.tui === false) return true; + if (opts.ci) return true; + return isRunningInAgent() || isHeadless(); +}; + const runHeadlessForTarget = async (target: Target, opts: CommanderOpts) => { - const ciMode = opts.ci || isRunningInAgent() || isHeadless(); + const ciMode = shouldRunHeadless(opts); const timeoutMs = opts.timeout ? Option.some(opts.timeout) : ciMode @@ -122,15 +154,18 @@ const runHeadlessForTarget = async (target: Target, opts: CommanderOpts) => { headed: ciMode ? false : (opts.headed ?? false), ci: ciMode, noCookies: opts.noCookies ?? ciMode, + browserProfileIds: opts.browserProfile ?? [], timeoutMs, - output: opts.output ?? "text", - baseUrl: opts.url?.join(", "), + reporter: opts.reporter, + replayHost: opts.replayHost, + testId: opts.testId, }); }; -const SKILL_DIR = join(".agents", "skills", "expect"); +const SKILL_DIR = path.join(".agents", "skills", "expect"); -const isSkillInstalled = (): boolean => existsSync(join(process.cwd(), SKILL_DIR, "SKILL.md")); +const isSkillInstalled = (): boolean => + fs.existsSync(path.join(process.cwd(), SKILL_DIR, "SKILL.md")); const promptSkillInstall = async () => { if (isSkillInstalled()) return; @@ -203,6 +238,28 @@ program await runAuditCommand(); }); +program + .command("list-browsers") + .description("list detected browser profiles available for --browser-profile") + .action(async () => { + const browsers = await Browsers.use((b) => b.list).pipe( + Effect.provide(layerLive), + Effect.provide(NodeServices.layer), + Effect.runPromise, + ); + + if (browsers.length === 0) { + console.log("No browser profiles detected."); + return; + } + + console.log("Detected browser profiles:\n"); + for (const browser of browsers) { + console.log(` ${browser.displayName} (id: ${browser.id})`); + } + console.log(`\nUsage: expect --browser-profile ${browsers[0].id}`); + }); + program .command("watch") .description("watch for file changes and auto-run browser tests") @@ -216,11 +273,29 @@ program .option("--headed", "show a visible browser window during tests") .option("--no-cookies", "skip system browser cookie extraction") .option("-u, --url ", "base URL(s) for the dev server") - .option("--replay-host ", "website host for live replay viewer", "https://expect.dev") + .option("--replay-host ", "website host for live replay viewer", DEFAULT_REPLAY_HOST) .action(async (opts: CommanderOpts) => { await runWatchCommand(opts); }); +program + .command("viewer") + .description("open the replay viewer to watch recorded test sessions") + .option("--verbose", "enable verbose logging") + .option( + "-a, --agent ", + "agent provider to use (claude, codex, copilot, gemini, cursor, opencode, or droid)", + ) + .option("--replay-host ", "website host for live replay viewer") + .action((...args: Array) => { + const opts = args[0] as { + verbose?: boolean; + agent?: AgentBackend; + replayHost?: string; + }; + runViewer(opts); + }); + program.action(async () => { const opts = program.opts(); const target = opts.target ?? "changes"; @@ -229,7 +304,7 @@ program.action(async () => { program.error(`Unknown target: ${target}. Use ${TARGETS.join(", ")}.`); } - if (opts.ci || isRunningInAgent() || isHeadless()) return runHeadlessForTarget(target, opts); + if (shouldRunHeadless(opts)) return runHeadlessForTarget(target, opts); await promptSkillInstall(); @@ -241,7 +316,6 @@ program.action(async () => { usePreferencesStore.setState({ verbose: opts.verbose ?? false, browserHeaded: opts.headed ?? false, - replayHost: opts.replayHost ?? "https://expect.dev", }); if (opts.url) { usePreferencesStore.setState({ cliBaseUrls: opts.url }); diff --git a/apps/cli/src/layers.ts b/apps/cli/src/layers.ts index 8c7c632e5..4d909bbd1 100644 --- a/apps/cli/src/layers.ts +++ b/apps/cli/src/layers.ts @@ -1,27 +1,77 @@ -import { Layer, References } from "effect"; +import { Function as F, Layer, References } from "effect"; +import { NodeServices } from "@effect/platform-node"; import { DevTools } from "effect/unstable/devtools"; -import { FlowStorage, Reporter, Updates, Watch } from "@expect/supervisor"; -import type { AgentBackend } from "@expect/agent"; +import { + Executor, + FlowStorage, + Git, + ArtifactStore, + OutputReporter, + Reporter, + Updates, + Watch, +} from "@expect/supervisor"; + +import { Agent, AgentBackend } from "@expect/agent"; import { RrVideo } from "@expect/browser"; +import { layerLive as cookiesLayerLive } from "@expect/cookies"; import { Analytics, DebugFileLoggerLayer, Tracing } from "@expect/shared/observability"; -import { layerSdk } from "expect-sdk/effect"; +import { CurrentPlanId, PlanId } from "@expect/shared/models"; +import { layerArtifactRpcServer, layerArtifactViewerProxy } from "./artifact-server"; +import { ReplayHost } from "./replay-host"; + +interface LayerCliOptions { + verbose: boolean; + agent: AgentBackend; + reporter?: "json" | "github-actions"; + timeoutMs?: number; + replayHost?: string; + testId?: string; +} + +export const layerCli = ({ + verbose, + agent, + reporter, + timeoutMs, + replayHost, + testId, +}: LayerCliOptions) => { + const currentPlanId = Layer.succeed( + CurrentPlanId, + PlanId.makeUnsafe(testId ?? crypto.randomUUID()), + ); -export const layerCli = ({ verbose, agent }: { verbose: boolean; agent: AgentBackend }) => { - const sdkLayer = layerSdk(agent ?? "claude", process.cwd()); - const watchLayer = Watch.layer.pipe(Layer.provide(sdkLayer)); + const outputReporterLayer = + reporter === "json" + ? OutputReporter.layerJson + : reporter === "github-actions" + ? OutputReporter.layerGitHubActions({ agent, timeoutMs }) + : OutputReporter.layerStdoutNoop({ agent, timeoutMs }); return Layer.mergeAll( - sdkLayer, + Executor.layer, Reporter.layer, Updates.layer, FlowStorage.layer, DevTools.layer(), - Analytics.layerPostHog, + Analytics.layerDev, RrVideo.layer, - watchLayer, + Watch.layer, + layerArtifactRpcServer, + layerArtifactViewerProxy, ).pipe( + Layer.provideMerge(outputReporterLayer), + Layer.provideMerge(Agent.layerFor(agent ?? "claude")), + Layer.provideMerge(currentPlanId), + replayHost ? Layer.provideMerge(Layer.succeed(ReplayHost, replayHost)) : F.identity, Layer.provide(DebugFileLoggerLayer), Layer.provide(Tracing.layerAxiom("expect-cli")), - Layer.provideMerge(Layer.succeed(References.MinimumLogLevel, verbose ? "All" : "Info")), + Layer.provideMerge(cookiesLayerLive), + Layer.provideMerge(Git.withRepoRoot(process.cwd())), + /** @note(rasmus): w json reporter we cant have any logs out */ + Layer.provideMerge( + Layer.succeed(References.MinimumLogLevel, verbose && reporter !== "json" ? "All" : "Error"), + ), ); }; diff --git a/apps/cli/src/replay-host.ts b/apps/cli/src/replay-host.ts new file mode 100644 index 000000000..db6868a91 --- /dev/null +++ b/apps/cli/src/replay-host.ts @@ -0,0 +1,6 @@ +import { ServiceMap } from "effect"; +import { DEFAULT_REPLAY_HOST } from "@expect/shared"; + +export const ReplayHost = ServiceMap.Reference("expect-cli/ReplayHost", { + defaultValue: () => DEFAULT_REPLAY_HOST, +}); diff --git a/apps/cli/src/stores/use-navigation.ts b/apps/cli/src/stores/use-navigation.ts index ae7989aee..9de236866 100644 --- a/apps/cli/src/stores/use-navigation.ts +++ b/apps/cli/src/stores/use-navigation.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import * as Data from "effect/Data"; import type { ChangesFor, SavedFlow, TestReport } from "@expect/shared/models"; import type { DevServerHint } from "@expect/shared/prompts"; +import type { Browser } from "@expect/cookies"; import { containsUrl } from "../utils/detect-url"; export type { DevServerHint } from "@expect/shared/prompts"; @@ -14,13 +15,13 @@ export type Screen = Data.TaggedEnum<{ changesFor: ChangesFor; instruction: string; savedFlow?: SavedFlow; - cookieBrowserKeys?: readonly string[]; + cookieImportProfiles?: readonly Browser[]; }; Testing: { changesFor: ChangesFor; instruction: string; savedFlow?: SavedFlow; - cookieBrowserKeys?: readonly string[]; + cookieImportProfiles?: readonly Browser[]; baseUrls?: readonly string[]; devServerHints?: readonly DevServerHint[]; }; @@ -29,7 +30,7 @@ export type Screen = Data.TaggedEnum<{ Watch: { changesFor: ChangesFor; instruction: string; - cookieBrowserKeys?: readonly string[]; + cookieImportProfiles?: readonly Browser[]; baseUrl?: string; }; AgentPicker: {}; @@ -40,13 +41,8 @@ export const screenForTestingOrPortPicker = (props: { changesFor: ChangesFor; instruction: string; savedFlow?: SavedFlow; - cookieBrowserKeys?: readonly string[]; - baseUrls?: readonly string[]; -}): Screen => { - if (props.baseUrls && props.baseUrls.length > 0) return Screen.Testing(props); - if (containsUrl(props.instruction)) return Screen.Testing(props); - return Screen.PortPicker(props); -}; + cookieImportProfiles?: readonly Browser[]; +}): Screen => (containsUrl(props.instruction) ? Screen.Testing(props) : Screen.PortPicker(props)); interface NavigationStore { screen: Screen; diff --git a/apps/cli/src/stores/use-preferences.ts b/apps/cli/src/stores/use-preferences.ts index 070f2d254..b61da3afe 100644 --- a/apps/cli/src/stores/use-preferences.ts +++ b/apps/cli/src/stores/use-preferences.ts @@ -8,7 +8,6 @@ interface PreferencesStore { agentBackend: AgentBackend; verbose: boolean; browserHeaded: boolean; - replayHost: string; autoSaveFlows: boolean; notifications: boolean | undefined; instructionHistory: string[]; @@ -27,7 +26,6 @@ export const usePreferencesStore = create()( agentBackend: "claude", verbose: false, browserHeaded: false, - replayHost: "https://expect.dev", autoSaveFlows: true, notifications: undefined, instructionHistory: [], diff --git a/apps/cli/src/stores/use-project-preferences.ts b/apps/cli/src/stores/use-project-preferences.ts index a46475144..3b706ea8e 100644 --- a/apps/cli/src/stores/use-project-preferences.ts +++ b/apps/cli/src/stores/use-project-preferences.ts @@ -1,11 +1,15 @@ +import { Schema } from "effect"; import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import { projectPreferencesStorage } from "@expect/supervisor"; +import { type Browser, Browser as BrowserSchema } from "@expect/cookies"; + +const decodeBrowsers = Schema.decodeUnknownSync(Schema.Array(BrowserSchema)); interface ProjectPreferencesStore { - cookieBrowserKeys: string[]; - setCookieBrowserKeys: (keys: string[]) => void; - clearCookieBrowserKeys: () => void; + cookieImportProfiles: readonly Browser[]; + setCookieImportProfiles: (profiles: readonly Browser[]) => void; + clearCookieImportProfiles: () => void; lastBaseUrl: string | undefined; setLastBaseUrl: (url: string | undefined) => void; } @@ -13,15 +17,25 @@ interface ProjectPreferencesStore { export const useProjectPreferencesStore = create()( persist( (set) => ({ - cookieBrowserKeys: [], - setCookieBrowserKeys: (keys: string[]) => set({ cookieBrowserKeys: keys }), - clearCookieBrowserKeys: () => set({ cookieBrowserKeys: [] }), + cookieImportProfiles: [], + setCookieImportProfiles: (profiles: readonly Browser[]) => + set({ cookieImportProfiles: profiles }), + clearCookieImportProfiles: () => set({ cookieImportProfiles: [] }), lastBaseUrl: undefined, setLastBaseUrl: (url: string | undefined) => set({ lastBaseUrl: url }), }), { name: "project-preferences", storage: createJSONStorage(() => projectPreferencesStorage), + merge: (persisted, current) => { + const state = persisted as Partial | undefined; + const profiles = state?.cookieImportProfiles; + return { + ...current, + ...state, + cookieImportProfiles: profiles ? decodeBrowsers(profiles) : [], + }; + }, }, ), ); diff --git a/apps/cli/src/utils/ci-reporter.ts b/apps/cli/src/utils/ci-reporter.ts deleted file mode 100644 index 4ee274768..000000000 --- a/apps/cli/src/utils/ci-reporter.ts +++ /dev/null @@ -1,143 +0,0 @@ -import pc from "picocolors"; -import figures from "figures"; -import prettyMs from "pretty-ms"; -import { formatElapsedTime } from "./format-elapsed-time"; - -interface CiReporterOptions { - version: string; - agent: string; - timeoutMs: number | undefined; - isGitHubActions: boolean; -} - -const ghaEscape = (text: string) => text.replace(/\r?\n/g, " ").replace(/::/g, ": :"); - -const writeStderr = (text: string) => process.stderr.write(text + "\n"); -const writeStdout = (text: string) => process.stdout.write(text + "\n"); - -export const createCiReporter = (options: CiReporterOptions) => { - const header = () => { - const timeoutLabel = - options.timeoutMs !== undefined - ? ` · timeout ${prettyMs(options.timeoutMs, { compact: true })}` - : ""; - writeStderr(""); - writeStderr( - ` ${pc.bold(pc.cyan("expect"))} ${pc.dim(`v${options.version}`)} ${pc.dim("CI")} · ${pc.dim(options.agent)}${pc.dim(timeoutLabel)}`, - ); - }; - - const planTitle = (title: string, baseUrl: string | undefined) => { - writeStderr(""); - writeStderr(` ${pc.bold(title)}`); - if (baseUrl) { - writeStderr(` ${pc.dim(baseUrl)}`); - } - }; - - const groupOpen = () => { - if (options.isGitHubActions) { - writeStdout("::group::expect test execution"); - } - }; - - const groupClose = () => { - if (options.isGitHubActions) { - writeStdout("::endgroup::"); - } - }; - - const stepStarted = (title: string) => { - writeStderr(` ${pc.dim(figures.circle)} ${pc.dim(title)}`); - }; - - const stepCompleted = (title: string, durationMs: number | undefined) => { - const timeLabel = - durationMs !== undefined ? ` ${pc.dim(`(${formatElapsedTime(durationMs)})`)}` : ""; - writeStderr(` ${pc.green(figures.tick)} ${title}${timeLabel}`); - }; - - const stepFailed = (title: string, message: string, durationMs: number | undefined) => { - const timeLabel = - durationMs !== undefined ? ` ${pc.dim(`(${formatElapsedTime(durationMs)})`)}` : ""; - writeStderr(` ${pc.red(figures.cross)} ${title}${timeLabel}`); - writeStderr(` ${pc.red(message)}`); - if (options.isGitHubActions) { - writeStdout(`::error title=${ghaEscape(title)} failed::${ghaEscape(message)}`); - } - }; - - const stepSkipped = (title: string, reason: string) => { - writeStderr(` ${pc.yellow(figures.arrowRight)} ${title} ${pc.yellow("[skipped]")}`); - if (reason) { - writeStderr(` ${pc.dim(reason)}`); - } - }; - - const heartbeat = (elapsedMs: number) => { - writeStderr(pc.dim(` Still running… (${prettyMs(elapsedMs, { compact: true })} elapsed)`)); - }; - - const summary = ( - passed: number, - failed: number, - skipped: number, - total: number, - durationMs: number, - ) => { - writeStderr(""); - const parts: string[] = []; - if (passed > 0) parts.push(pc.green(`${passed} passed`)); - if (failed > 0) parts.push(pc.red(`${failed} failed`)); - if (skipped > 0) parts.push(pc.yellow(`${skipped} skipped`)); - writeStderr(` ${pc.bold("Tests")} ${parts.join(pc.dim(" | "))} ${pc.dim(`(${total})`)}`); - writeStderr(` ${pc.bold("Time")} ${formatElapsedTime(durationMs)}`); - }; - - const artifacts = ( - videoPath?: string, - replayUrl?: string, - screenshotPaths?: readonly string[], - ) => { - if (!videoPath && !replayUrl && (!screenshotPaths || screenshotPaths.length === 0)) return; - writeStderr(""); - writeStderr(` ${pc.bold("Artifacts")}`); - if (videoPath) { - writeStderr(` ${pc.dim("Video")} ${videoPath}`); - } - if (replayUrl) { - writeStderr(` ${pc.dim("Replay")} ${replayUrl}`); - } - if (screenshotPaths && screenshotPaths.length > 0) { - for (const screenshotPath of screenshotPaths) { - writeStderr(` ${pc.dim("Screenshot")} ${screenshotPath}`); - } - } - }; - - const timeoutError = (timeoutMs: number) => { - const message = `Execution timed out after ${prettyMs(timeoutMs, { compact: true })}`; - writeStderr(""); - writeStderr(` ${pc.red(figures.cross)} ${pc.red(pc.bold("Timeout"))} ${pc.red(message)}`); - if (options.isGitHubActions) { - writeStdout(`::error title=Execution timed out::${ghaEscape(message)}`); - } - }; - - return { - header, - planTitle, - groupOpen, - groupClose, - stepStarted, - stepCompleted, - stepFailed, - stepSkipped, - heartbeat, - summary, - artifacts, - timeoutError, - } as const; -}; - -export type CiReporter = ReturnType; diff --git a/apps/cli/src/utils/detect-projects.ts b/apps/cli/src/utils/detect-projects.ts index 3e2e37d14..de4cbe751 100644 --- a/apps/cli/src/utils/detect-projects.ts +++ b/apps/cli/src/utils/detect-projects.ts @@ -1,5 +1,5 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { Predicate } from "effect"; import { LOCK_FILE_TO_AGENT, PROJECT_SCAN_MAX_DEPTH, type PackageManager } from "../constants"; @@ -68,10 +68,10 @@ const IGNORED_DIRECTORIES = new Set([ ]); const readPackageJson = (projectPath: string): Record | undefined => { - const packageJsonPath = join(projectPath, "package.json"); - if (!existsSync(packageJsonPath)) return undefined; + const packageJsonPath = path.join(projectPath, "package.json"); + if (!fs.existsSync(packageJsonPath)) return undefined; try { - const parsed: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + const parsed: unknown = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); return Predicate.isObject(parsed) ? (parsed as Record) : undefined; } catch { return undefined; @@ -125,14 +125,14 @@ const extractPortFromCommand = (command: string): number | undefined => { }; const detectPackageManager = (projectPath: string): PackageManager => { - let current = resolve(projectPath); - const root = resolve("/"); + let current = path.resolve(projectPath); + const root = path.resolve("/"); while (current !== root) { for (const [lockFile, manager] of Object.entries(LOCK_FILE_TO_AGENT)) { - if (existsSync(join(current, lockFile))) return manager; + if (fs.existsSync(path.join(current, lockFile))) return manager; } - const parent = resolve(current, ".."); + const parent = path.resolve(current, ".."); if (parent === current) break; current = parent; } @@ -150,7 +150,7 @@ const buildProject = (projectPath: string): DetectedProject | undefined => { const devCommand = getDevCommand(packageJson); const commandPort = devCommand ? extractPortFromCommand(devCommand) : undefined; const name = - typeof packageJson["name"] === "string" ? packageJson["name"] : basename(projectPath); + typeof packageJson["name"] === "string" ? packageJson["name"] : path.basename(projectPath); return { name, @@ -165,10 +165,10 @@ const buildProject = (projectPath: string): DetectedProject | undefined => { const getWorkspacePatterns = (projectRoot: string): string[] => { const patterns: string[] = []; - const pnpmWorkspacePath = join(projectRoot, "pnpm-workspace.yaml"); - if (existsSync(pnpmWorkspacePath)) { + const pnpmWorkspacePath = path.join(projectRoot, "pnpm-workspace.yaml"); + if (fs.existsSync(pnpmWorkspacePath)) { try { - const content = readFileSync(pnpmWorkspacePath, "utf-8"); + const content = fs.readFileSync(pnpmWorkspacePath, "utf-8"); let inPackages = false; for (const line of content.split("\n")) { @@ -215,28 +215,30 @@ const getWorkspacePatterns = (projectRoot: string): string[] => { const expandPattern = (projectRoot: string, pattern: string): string[] => { const isGlob = pattern.endsWith("/*"); const cleanPattern = pattern.replace(/\/\*$/, ""); - const basePath = join(projectRoot, cleanPattern); + const basePath = path.join(projectRoot, cleanPattern); - if (!existsSync(basePath)) return []; + if (!fs.existsSync(basePath)) return []; if (!isGlob) { - return existsSync(join(basePath, "package.json")) ? [basePath] : []; + return fs.existsSync(path.join(basePath, "package.json")) ? [basePath] : []; } try { - return readdirSync(basePath, { withFileTypes: true }) + return fs + .readdirSync(basePath, { withFileTypes: true }) .filter( - (entry) => entry.isDirectory() && existsSync(join(basePath, entry.name, "package.json")), + (entry) => + entry.isDirectory() && fs.existsSync(path.join(basePath, entry.name, "package.json")), ) - .map((entry) => join(basePath, entry.name)); + .map((entry) => path.join(basePath, entry.name)); } catch { return []; } }; const hasMonorepoMarkers = (projectRoot: string): boolean => { - if (existsSync(join(projectRoot, "pnpm-workspace.yaml"))) return true; - if (existsSync(join(projectRoot, "lerna.json"))) return true; + if (fs.existsSync(path.join(projectRoot, "pnpm-workspace.yaml"))) return true; + if (fs.existsSync(path.join(projectRoot, "lerna.json"))) return true; const packageJson = readPackageJson(projectRoot); return Boolean(packageJson?.["workspaces"]); @@ -261,17 +263,17 @@ const scanDirectory = ( maxDepth: number, currentDepth: number = 0, ): DetectedProject[] => { - if (currentDepth >= maxDepth || !existsSync(directory)) return []; + if (currentDepth >= maxDepth || !fs.existsSync(directory)) return []; const projects: DetectedProject[] = []; try { - for (const entry of readdirSync(directory, { withFileTypes: true })) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (IGNORED_DIRECTORIES.has(entry.name)) continue; if (entry.name.startsWith(".")) continue; - const entryPath = join(directory, entry.name); + const entryPath = path.join(directory, entry.name); const project = buildProject(entryPath); if (project) { projects.push(project); @@ -288,20 +290,20 @@ const scanDirectory = ( }; const scanSiblingProjects = (rootPath: string): DetectedProject[] => { - const parentDir = join(rootPath, ".."); - const resolvedParent = resolve(parentDir); + const parentDir = path.join(rootPath, ".."); + const resolvedParent = path.resolve(parentDir); if (resolvedParent === rootPath) return []; const projects: DetectedProject[] = []; try { - for (const entry of readdirSync(resolvedParent, { withFileTypes: true })) { + for (const entry of fs.readdirSync(resolvedParent, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (IGNORED_DIRECTORIES.has(entry.name)) continue; if (entry.name.startsWith(".")) continue; - const entryPath = join(resolvedParent, entry.name); - if (resolve(entryPath) === rootPath) continue; + const entryPath = path.join(resolvedParent, entry.name); + if (path.resolve(entryPath) === rootPath) continue; const project = buildProject(entryPath); if (project) { @@ -321,13 +323,13 @@ const scanSiblingProjects = (rootPath: string): DetectedProject[] => { }; export const detectNearbyProjects = (rootPath: string = process.cwd()): DetectedProject[] => { - const resolvedRoot = resolve(rootPath); + const resolvedRoot = path.resolve(rootPath); const projects: DetectedProject[] = []; const seenPaths = new Set(); const addProjects = (found: DetectedProject[]) => { for (const project of found) { - const normalized = resolve(project.path); + const normalized = path.resolve(project.path); if (seenPaths.has(normalized)) continue; seenPaths.add(normalized); projects.push(project); diff --git a/apps/cli/src/utils/extract-close-artifacts.ts b/apps/cli/src/utils/extract-close-artifacts.ts deleted file mode 100644 index c3c62e811..000000000 --- a/apps/cli/src/utils/extract-close-artifacts.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { ExecutionEvent } from "@expect/shared/models"; -import { pathToFileURL } from "node:url"; - -const REPLAY_SESSION_PREFIX = "rrweb replay:"; -const REPLAY_REPORT_PREFIX = "rrweb report:"; -const PLAYWRIGHT_VIDEO_PREFIX = "Playwright video:"; -const SCREENSHOT_PREFIX = "Screenshot:"; - -export interface CloseArtifacts { - readonly localReplayUrl: string | undefined; - readonly videoUrl: string | undefined; - readonly replayPath: string | undefined; - readonly videoPath: string | undefined; - readonly replaySessionPath: string | undefined; - readonly screenshotPaths: readonly string[]; -} - -export const extractCloseArtifacts = (events: readonly ExecutionEvent[]): CloseArtifacts => { - const closeResult = events - .slice() - .reverse() - .find( - (event) => - event._tag === "ToolResult" && - event.toolName === "close" && - !event.isError && - event.result.length > 0, - ); - if (!closeResult || closeResult._tag !== "ToolResult") { - return { - localReplayUrl: undefined, - videoUrl: undefined, - replayPath: undefined, - videoPath: undefined, - replaySessionPath: undefined, - screenshotPaths: [], - }; - } - - const lines = closeResult.result - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - const extractValue = (prefix: string) => { - const raw = lines - .find((line) => line.startsWith(prefix)) - ?.replace(prefix, "") - .trim(); - return raw && raw.length > 0 ? raw : undefined; - }; - - const replaySessionPath = extractValue(REPLAY_SESSION_PREFIX); - const replayPath = extractValue(REPLAY_REPORT_PREFIX); - const videoPath = extractValue(PLAYWRIGHT_VIDEO_PREFIX); - const screenshotPaths = lines - .filter((line) => line.startsWith(SCREENSHOT_PREFIX)) - .map((line) => line.replace(SCREENSHOT_PREFIX, "").trim()) - .filter((value) => value.length > 0); - - return { - localReplayUrl: replayPath ? pathToFileURL(replayPath).href : undefined, - videoUrl: videoPath ? pathToFileURL(videoPath).href : undefined, - replayPath, - videoPath, - replaySessionPath, - screenshotPaths, - }; -}; diff --git a/apps/cli/src/utils/gha-output.ts b/apps/cli/src/utils/gha-output.ts deleted file mode 100644 index 8f5f831e5..000000000 --- a/apps/cli/src/utils/gha-output.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Config, Effect, Option } from "effect"; -import { appendFileSync } from "node:fs"; - -export const writeGhaOutputs = Effect.fn("writeGhaOutputs")(function* ( - status: string, - videoPath: string | undefined, - replayPath: string | undefined, -) { - const githubOutputPath = yield* Config.option(Config.string("GITHUB_OUTPUT")); - if (Option.isNone(githubOutputPath)) return; - - const outputLines: string[] = [`result=${status}`]; - if (videoPath) { - outputLines.push(`video_path=${videoPath}`); - } - if (replayPath) { - outputLines.push(`replay_path=${replayPath}`); - } - - yield* Effect.sync(() => appendFileSync(githubOutputPath.value, outputLines.join("\n") + "\n")); -}); - -export const writeGhaStepSummary = Effect.fn("writeGhaStepSummary")(function* ( - reportText: string, - status: string, - videoPath: string | undefined, - replayPath: string | undefined, -) { - const summaryPath = yield* Config.option(Config.string("GITHUB_STEP_SUMMARY")); - if (Option.isNone(summaryPath)) return; - - const badge = status === "passed" ? "**Result: PASSED**" : "**Result: FAILED**"; - const artifactLines: string[] = []; - if (videoPath) { - artifactLines.push("**Video:** uploaded as artifact (see workflow artifacts above)"); - } - if (replayPath) { - artifactLines.push("**Replay:** uploaded as artifact (see workflow artifacts above)"); - } - const artifactSection = artifactLines.length > 0 ? `\n${artifactLines.join("\n")}\n` : ""; - const maxBacktickRun = (reportText.match(/`+/g) ?? []).reduce( - (max, run) => Math.max(max, run.length), - 2, - ); - const fence = "`".repeat(maxBacktickRun + 1); - const summary = `## expect test results\n\n${badge}\n\n${fence}\n${reportText}\n${fence}\n${artifactSection}`; - - yield* Effect.sync(() => appendFileSync(summaryPath.value, summary)); -}); diff --git a/apps/cli/src/utils/load-replay-events.ts b/apps/cli/src/utils/load-replay-events.ts deleted file mode 100644 index 7e2a3586c..000000000 --- a/apps/cli/src/utils/load-replay-events.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { loadSession } from "@expect/browser"; -import { Effect } from "effect"; - -interface LoadReplayEventsOptions { - readonly liveViewUrl: string; - readonly replaySessionPath?: string; -} - -const fetchLiveReplayEvents = Effect.fn("fetchLiveReplayEvents")(function* (liveViewUrl: string) { - return yield* Effect.tryPromise(async () => { - const response = await fetch(`${liveViewUrl}/latest.json`); - if (!response.ok) return undefined; - const data: unknown = await response.json(); - return Array.isArray(data) ? data : undefined; - }).pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to fetch live replay events", { cause, liveViewUrl }).pipe( - Effect.as(undefined), - ), - ), - ); -}); - -export const loadReplayEvents = Effect.fn("loadReplayEvents")(function* ( - options: LoadReplayEventsOptions, -) { - if (options.replaySessionPath) { - const finalizedEvents = yield* loadSession(options.replaySessionPath).pipe( - Effect.catchTag("SessionLoadError", (error) => - Effect.logWarning("Failed to load finalized replay session", { - path: options.replaySessionPath, - message: error.message, - }).pipe(Effect.as(undefined)), - ), - ); - if (finalizedEvents && finalizedEvents.length > 0) { - return finalizedEvents; - } - } - - return yield* fetchLiveReplayEvents(options.liveViewUrl); -}); diff --git a/apps/cli/src/utils/push-step-state.ts b/apps/cli/src/utils/push-step-state.ts deleted file mode 100644 index 8e93ec420..000000000 --- a/apps/cli/src/utils/push-step-state.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { DateTime, Effect, Option } from "effect"; -import type { ExecutedTestPlan } from "@expect/shared/models"; -import type { ViewerRunState, ViewerStepEvent } from "@expect/browser/mcp"; - -const optionDateTimeToMs = (value: Option.Option): number | undefined => - Option.isSome(value) ? Number(DateTime.toEpochMillis(value.value)) : undefined; - -const deriveStatusFromSteps = (steps: ExecutedTestPlan["steps"]): ViewerRunState["status"] => { - if (steps.length === 0) return "running"; - const allDone = steps.every((step) => step.status === "passed" || step.status === "failed"); - if (!allDone) return "running"; - const anyFailed = steps.some((step) => step.status === "failed"); - return anyFailed ? "failed" : "passed"; -}; - -export const toViewerRunState = (executed: ExecutedTestPlan): ViewerRunState => { - const runFinishedEvent = executed.events.find((event) => event._tag === "RunFinished"); - - const status: ViewerRunState["status"] = - runFinishedEvent && runFinishedEvent._tag === "RunFinished" - ? runFinishedEvent.status - : deriveStatusFromSteps(executed.steps); - - const summary = - runFinishedEvent && runFinishedEvent._tag === "RunFinished" - ? runFinishedEvent.summary - : undefined; - - const steps: ViewerStepEvent[] = executed.steps.map((step) => ({ - stepId: step.id, - title: step.title, - status: step.status, - summary: Option.isSome(step.summary) ? step.summary.value : undefined, - startedAtMs: optionDateTimeToMs(step.startedAt), - endedAtMs: optionDateTimeToMs(step.endedAt), - })); - - return { - title: executed.title, - status, - summary, - steps, - }; -}; - -export const pushStepState = Effect.fn("pushStepState")(function* ( - liveViewUrl: string, - state: ViewerRunState, -) { - yield* Effect.tryPromise(() => - fetch(`${liveViewUrl}/steps`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(state), - }), - ).pipe(Effect.catchCause(() => Effect.void)); -}); diff --git a/apps/cli/src/utils/replay-proxy-server.ts b/apps/cli/src/utils/replay-proxy-server.ts deleted file mode 100644 index e4a4cf0ff..000000000 --- a/apps/cli/src/utils/replay-proxy-server.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { request as httpRequest } from "node:http"; -import type { IncomingMessage } from "node:http"; -import type { Duplex } from "node:stream"; -import { Effect, Schema } from "effect"; -import { Hono } from "hono"; -import { proxy } from "hono/proxy"; -import { serve } from "@hono/node-server"; - -export class ReplayProxyStartError extends Schema.ErrorClass( - "ReplayProxyStartError", -)({ - _tag: Schema.tag("ReplayProxyStartError"), - cause: Schema.String, -}) { - message = `Failed to start replay proxy: ${this.cause}`; -} - -interface StartReplayProxyOptions { - readonly replayHost: string; - readonly liveViewUrl: string; -} - -export interface ReplayProxyHandle { - readonly url: string; - readonly close: Effect.Effect; -} - -const proxyWebSocketUpgrade = ( - request: IncomingMessage, - socket: Duplex, - head: Buffer, - upstreamOrigin: string, -) => { - const upstreamUrl = new URL(request.url ?? "/", upstreamOrigin); - - const upstreamReq = httpRequest({ - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: "GET", - headers: { ...request.headers, host: upstreamUrl.host }, - }); - - upstreamReq.on("upgrade", (_res, upstreamSocket, upstreamHead) => { - socket.write( - [ - "HTTP/1.1 101 Switching Protocols", - "Upgrade: websocket", - "Connection: Upgrade", - `Sec-WebSocket-Accept: ${_res.headers["sec-websocket-accept"]}`, - "", - "", - ].join("\r\n"), - ); - - if (upstreamHead.length > 0) socket.write(upstreamHead); - if (head.length > 0) upstreamSocket.write(head); - - upstreamSocket.pipe(socket); - socket.pipe(upstreamSocket); - - socket.on("error", () => upstreamSocket.destroy()); - upstreamSocket.on("error", () => socket.destroy()); - }); - - upstreamReq.on("error", () => socket.destroy()); - upstreamReq.end(); -}; - -export const startReplayProxy = Effect.fn("startReplayProxy")(function* ( - options: StartReplayProxyOptions, -) { - const app = new Hono(); - - let cachedEvents: unknown[] = []; - let cachedSteps: Record = { title: "", status: "running", steps: [] }; - let runDone = false; - - const markDoneIfTerminal = async () => { - const status = cachedSteps?.status; - if (status === "passed" || status === "failed") { - try { - const response = await fetch(`${options.liveViewUrl}/latest.json`); - if (response.ok) { - const data: unknown[] = await response.json(); - if (Array.isArray(data) && data.length > 0) cachedEvents = data; - } - } catch {} - runDone = true; - cachedSteps = { ...cachedSteps, done: true }; - } - }; - - app.get("/latest.json", async (context) => { - if (runDone) return context.json(cachedEvents); - try { - const upstream = await fetch(`${options.liveViewUrl}/latest.json`); - if (!upstream.ok) return context.json(cachedEvents); - const data: unknown[] = await upstream.json(); - if (Array.isArray(data) && data.length > 0) cachedEvents = data; - return context.json(data); - } catch { - return context.json(cachedEvents); - } - }); - - app.post("/latest.json", async (context) => { - try { - const data: unknown[] = await context.req.json(); - if (Array.isArray(data)) cachedEvents = data; - return new Response(undefined, { status: 204 }); - } catch { - return new Response(undefined, { status: 400 }); - } - }); - - app.get("/steps", async (context) => { - if (runDone) return context.json(cachedSteps); - try { - const upstream = await fetch(`${options.liveViewUrl}/steps`); - if (!upstream.ok) return context.json(cachedSteps); - const data = (await upstream.json()) as Record; - cachedSteps = data; - await markDoneIfTerminal(); - return context.json(cachedSteps); - } catch { - return context.json(cachedSteps); - } - }); - - app.post("/steps", async (context) => { - try { - const body = await context.req.text(); - const parsed = JSON.parse(body) as Record; - cachedSteps = parsed; - await markDoneIfTerminal(); - const upstream = await fetch(`${options.liveViewUrl}/steps`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body, - }); - return new Response(upstream.body, { - status: upstream.status, - headers: Object.fromEntries(upstream.headers.entries()), - }); - } catch { - return new Response(undefined, { status: 204 }); - } - }); - - const normalizedReplayHost = /^https?:\/\//.test(options.replayHost) - ? options.replayHost - : `http://${options.replayHost}`; - const replayHostParsed = new URL(normalizedReplayHost); - - app.all("/*", async (context) => { - const requestPath = context.req.path; - if (requestPath === "/latest.json" || requestPath === "/steps") { - return context.text("Not Found", 404); - } - - const upstreamUrl = new URL(requestPath, normalizedReplayHost); - upstreamUrl.search = new URL(context.req.url).search; - - try { - return await proxy(upstreamUrl.toString(), { - headers: { - "User-Agent": context.req.header("user-agent") ?? "", - Accept: context.req.header("accept") ?? "*/*", - Host: replayHostParsed.host, - }, - }); - } catch (error) { - console.error(`[replay-proxy] Failed to proxy ${requestPath} to ${upstreamUrl}:`, error); - return context.text(`Bad Gateway: could not reach ${replayHostParsed.host}`, 502); - } - }); - - app.onError((error, context) => { - console.error("[replay-proxy] Unhandled error:", error); - return context.text("Internal Server Error", 500); - }); - - const serverHandle = yield* Effect.try({ - try: () => serve({ fetch: app.fetch, port: 0 }), - catch: (cause) => new ReplayProxyStartError({ cause: String(cause) }), - }); - - serverHandle.on("upgrade", (request, socket, head) => { - proxyWebSocketUpgrade(request, socket, head, normalizedReplayHost); - }); - - const address = serverHandle.address(); - const port = - typeof address === "object" && address !== undefined && address !== null ? address.port : 0; - const proxyUrl = `http://localhost:${port}`; - - yield* Effect.logInfo("Replay proxy started", { - proxyUrl, - liveViewUrl: options.liveViewUrl, - }); - - return { - url: proxyUrl, - close: Effect.callback((resume) => { - serverHandle.close(() => resume(Effect.void)); - }), - } satisfies ReplayProxyHandle; -}); diff --git a/apps/cli/src/utils/run-test.ts b/apps/cli/src/utils/run-test.ts index f66389057..a9b6c56a3 100644 --- a/apps/cli/src/utils/run-test.ts +++ b/apps/cli/src/utils/run-test.ts @@ -1,21 +1,20 @@ -import { Config, Effect, Option, Schema, Stream } from "effect"; -import { type ChangesFor, CiResultOutput, CiStepResult } from "@expect/shared/models"; -import { Executor, ExecutedTestPlan, Reporter, Github } from "@expect/supervisor"; +import { Effect, Option, Stream, Schema, Cause } from "effect"; +import type { ChangesFor } from "@expect/shared/models"; +import { Browsers } from "@expect/cookies"; +import { Executor, OutputReporter, Reporter } from "@expect/supervisor"; import { Analytics } from "@expect/shared/observability"; import type { AgentBackend } from "@expect/agent"; -import { ExpectTimeoutError } from "expect-sdk/effect"; -import { VERSION, CI_HEARTBEAT_INTERVAL_MS } from "../constants"; import { layerCli } from "../layers"; import { playSound } from "./play-sound"; -import { stripUndefinedRequirement } from "./strip-undefined-requirement"; -import { extractCloseArtifacts } from "./extract-close-artifacts"; -import { RrVideo } from "@expect/browser"; -import { createCiReporter } from "./ci-reporter"; -import { writeGhaOutputs, writeGhaStepSummary } from "./gha-output"; -import { getStepElapsedMs, getTotalElapsedMs } from "./step-elapsed"; -import { formatElapsedTime } from "./format-elapsed-time"; -const COMMENT_MARKER = ""; +class ExecutionTimeoutError extends Schema.ErrorClass( + "ExecutionTimeoutError" +)({ + _tag: Schema.tag("ExecutionTimeoutError"), + timeoutMs: Schema.Number, +}) { + message = `expect execution timed out after ${this.timeoutMs}ms`; +} interface HeadlessRunOptions { changesFor: ChangesFor; @@ -25,392 +24,98 @@ interface HeadlessRunOptions { headed: boolean; ci: boolean; noCookies: boolean; + browserProfileIds: readonly string[]; timeoutMs: Option.Option; - output: "text" | "json"; - baseUrl?: string; + reporter?: "json" | "github-actions"; + replayHost?: string; + testId?: string; } export const runHeadless = (options: HeadlessRunOptions) => - Effect.runPromise( - stripUndefinedRequirement( - Effect.scoped( - Effect.gen(function* () { - const executor = yield* Executor; - const reporter = yield* Reporter; - const analytics = yield* Analytics; - - const sessionStartedAt = Date.now(); - yield* analytics.capture("session:started", { - mode: "headless", - skip_planning: false, - browser_headed: options.headed, - }); - - const isGitHubActions = - (yield* Config.string("GITHUB_ACTIONS").pipe(Config.withDefault(""))) !== ""; - const isJsonOutput = options.output === "json"; - - const timeoutMs = Option.getOrUndefined(options.timeoutMs); - const ciReporter = createCiReporter({ - version: VERSION, - agent: options.agent, - timeoutMs, - isGitHubActions, - }); - - if (!isJsonOutput) { - ciReporter.header(); - ciReporter.groupOpen(); - } - - const runStartedAt = Date.now(); - let lastOutputAt = Date.now(); - - if (options.ci && !isJsonOutput) { - yield* Effect.acquireRelease( - Effect.sync(() => - setInterval(() => { - const now = Date.now(); - if (now - lastOutputAt >= CI_HEARTBEAT_INTERVAL_MS) { - ciReporter.heartbeat(now - runStartedAt); - lastOutputAt = now; - } - }, CI_HEARTBEAT_INTERVAL_MS), - ), - (interval) => Effect.sync(() => clearInterval(interval)), - ); - } - - yield* analytics.capture("run:started", { plan_id: "direct" }); - const seenEvents = new Set(); - const printNewEvents = (executed: ExecutedTestPlan) => { - if (isJsonOutput) return; - for (const event of executed.events) { - if (seenEvents.has(event.id)) continue; - seenEvents.add(event.id); - lastOutputAt = Date.now(); - switch (event._tag) { - case "RunStarted": - ciReporter.planTitle(event.plan.title, Option.getOrUndefined(event.plan.baseUrl)); - break; - case "StepStarted": - ciReporter.stepStarted(event.title); - break; - case "StepCompleted": { - const step = executed.steps.find((step) => step.id === event.stepId); - const elapsed = step ? getStepElapsedMs(step) : undefined; - ciReporter.stepCompleted(event.summary, elapsed); - break; - } - case "StepFailed": { - const failedStep = executed.steps.find((step) => step.id === event.stepId); - const failedTitle = failedStep?.title ?? event.stepId; - const failedElapsed = failedStep ? getStepElapsedMs(failedStep) : undefined; - ciReporter.stepFailed(failedTitle, event.message, failedElapsed); - break; - } - case "StepSkipped": { - const skippedStep = executed.steps.find((step) => step.id === event.stepId); - const skippedTitle = skippedStep?.title ?? event.stepId; - ciReporter.stepSkipped(skippedTitle, event.reason); - break; - } - } - } - }; - - const executeStream = executor - .execute({ - changesFor: options.changesFor, - instruction: options.instruction, - isHeadless: !options.headed, - cookieBrowserKeys: [], - baseUrl: options.baseUrl, - }) - .pipe( - Stream.tap((executed) => Effect.sync(() => printNewEvents(executed))), - Stream.runLast, - Effect.map((option) => - Option.getOrElse( - option, - () => - new ExecutedTestPlan({ - id: "" as never, - changesFor: options.changesFor, - currentBranch: "", - diffPreview: "", - fileStats: [], - instruction: options.instruction, - baseUrl: undefined as never, - isHeadless: !options.headed, - cookieBrowserKeys: [], - testCoverage: Option.none(), - title: options.instruction, - rationale: "Direct execution", - steps: [], - events: [], - }), - ) - .finalizeTextBlock() - .synthesizeRunFinished(), - ), - ); - - const executeWithTimeout = - timeoutMs !== undefined - ? executeStream.pipe( - Effect.timeoutOrElse({ - duration: `${timeoutMs} millis`, - onTimeout: () => Effect.fail(new ExpectTimeoutError({ timeoutMs })), - }), - ) - : executeStream; - - const finalExecuted = yield* executeWithTimeout.pipe( - Effect.tapError(() => - Effect.sync(() => { - if (!isJsonOutput) ciReporter.groupClose(); - }), - ), - Effect.catchTag("ExpectTimeoutError", (error) => { - if (isJsonOutput) { - const resultOutput = new CiResultOutput({ - version: VERSION, - status: "failed" as const, - title: options.instruction, - duration_ms: error.timeoutMs, - steps: [], - artifacts: {}, - summary: `Timed out after ${formatElapsedTime(error.timeoutMs)}`, - }); - const jsonString = JSON.stringify( - Schema.encodeSync(CiResultOutput)(resultOutput), - undefined, - 2, - ); - process.stdout.write(jsonString + "\n"); - } else { - ciReporter.timeoutError(error.timeoutMs); - } - return Effect.sync(() => process.exit(1)); - }), + Effect.gen(function* () { + const executor = yield* Executor; + const reporter = yield* Reporter; + const outputReporter = yield* OutputReporter; + const analytics = yield* Analytics; + const browsers = yield* Browsers; + + const cookieImportProfiles = + options.noCookies || options.browserProfileIds.length === 0 + ? [] + : yield* Effect.forEach(options.browserProfileIds, (id) => + browsers.findById(id) ); - printNewEvents(finalExecuted); - - if (!isJsonOutput) { - ciReporter.groupClose(); - } - - const report = yield* reporter.report(finalExecuted); - - const statuses = report.stepStatuses; - const passedCount = report.steps.filter( - (step) => statuses.get(step.id)?.status === "passed", - ).length; - const failedCount = report.steps.filter( - (step) => statuses.get(step.id)?.status === "failed", - ).length; - const skippedCount = report.steps.filter( - (step) => statuses.get(step.id)?.status === "skipped", - ).length; - const totalDurationMs = getTotalElapsedMs(report.steps); - - yield* analytics.capture("run:completed", { - plan_id: finalExecuted.id ?? "direct", - passed: passedCount, - failed: failedCount, - step_count: finalExecuted.steps.length, - file_count: 0, - duration_ms: Date.now() - runStartedAt, - }); - - yield* analytics.capture("session:ended", { - session_ms: Date.now() - sessionStartedAt, - }); - yield* analytics.flush; - - const artifacts = extractCloseArtifacts(finalExecuted.events); - - let generatedVideoPath: string | undefined; - if (artifacts.replaySessionPath && artifacts.replaySessionPath.endsWith(".ndjson")) { - const latestJsonPath = artifacts.replaySessionPath.replace(/\.ndjson$/, "-latest.json"); - const videoOutputPath = artifacts.replaySessionPath.replace(/\.ndjson$/, ".mp4"); - const rrvideo = yield* RrVideo; - generatedVideoPath = yield* rrvideo - .convert({ - inputPath: latestJsonPath, - outputPath: videoOutputPath, - skipInactive: true, - speed: 1, - }) - .pipe( - Effect.catchTag("RrVideoConvertError", (error) => - Effect.sync(() => { - if (!isJsonOutput) { - process.stderr.write(`Warning: video generation failed: ${error.message}\n`); - } - return undefined; - }), - ), - ); - } - - const effectiveVideoPath = generatedVideoPath ?? artifacts.videoPath; - - if (!isJsonOutput) { - ciReporter.summary( - passedCount, - failedCount, - skippedCount, - report.steps.length, - totalDurationMs, - ); - ciReporter.artifacts( - effectiveVideoPath, - artifacts.localReplayUrl, - artifacts.screenshotPaths, - ); - for (const screenshotPath of artifacts.screenshotPaths) { - process.stdout.write(`Screenshot: ${screenshotPath}\n`); - } - } - - if (isGitHubActions) { - yield* writeGhaOutputs(report.status, effectiveVideoPath, artifacts.replayPath); - yield* writeGhaStepSummary( - report.toPlainText, - report.status, - effectiveVideoPath, - artifacts.replayPath, - ); - - yield* Effect.gen(function* () { - const github = yield* Github; - const cwd = process.cwd(); - const currentBranch = finalExecuted.currentBranch; - if (!currentBranch) return; - - const pullRequest = yield* github.findPullRequest(cwd, { - _tag: "Branch", - branchName: currentBranch, - }); - if (Option.isNone(pullRequest)) return; - - const statusEmoji = report.status === "passed" ? "\u2705" : "\u274c"; - const statusLabel = report.status === "passed" ? "Passed" : "Failed"; - const escapeTableCell = (text: string) => - text.replace(/\|/g, "\\|").replace(/\n/g, " "); - const stepRows = report.steps - .map((step) => { - const entry = statuses.get(step.id); - const stepStatus = entry?.status ?? "not-run"; - const stepIcon = - stepStatus === "passed" - ? "\u2713" - : stepStatus === "failed" - ? "\u2717" - : stepStatus === "skipped" - ? "\u2192" - : "\u2013"; - const stepSummary = entry?.summary ?? ""; - const stepTime = getStepElapsedMs(step); - const timeLabel = stepTime !== undefined ? formatElapsedTime(stepTime) : "-"; - const statusCell = - stepStatus === "failed" - ? `${stepIcon} ${escapeTableCell(stepSummary)}` - : stepIcon; - return `| ${escapeTableCell(step.title)} | ${statusCell} | ${timeLabel} |`; - }) - .join("\n"); - - const videoSection = effectiveVideoPath - ? `\n**Video:** see workflow artifacts\n` - : ""; - - const maxBacktickRun = (report.toPlainText.match(/`+/g) ?? []).reduce( - (max, run) => Math.max(max, run.length), - 2, - ); - const fence = "`".repeat(maxBacktickRun + 1); - - const commentBody = [ - COMMENT_MARKER, - `## expect test results`, - "", - `**${statusEmoji} ${statusLabel}** \u2014 ${report.steps.length} step${report.steps.length === 1 ? "" : "s"} in ${formatElapsedTime(totalDurationMs)}`, - "", - "| Step | Status | Time |", - "|------|--------|------|", - stepRows, - videoSection, - "
Full output", - "", - fence, - report.toPlainText, - fence, - "", - "
", - ].join("\n"); - - yield* github.upsertComment(cwd, pullRequest.value, COMMENT_MARKER, commentBody); - }).pipe( - Effect.provide(Github.layer), - Effect.catchTag("GitHubCommandError", (error) => - Effect.logWarning("PR comment failed", { error: error.message }), - ), - ); - } - - if (isJsonOutput) { - const stepResults = report.steps.map((step) => { - const entry = statuses.get(step.id); - const stepStatus = entry?.status ?? ("not-run" as const); - const elapsed = getStepElapsedMs(step); - return new CiStepResult({ - title: step.title, - status: stepStatus, - ...(elapsed !== undefined ? { duration_ms: elapsed } : {}), - ...(stepStatus === "failed" && entry?.summary ? { error: entry.summary } : {}), - }); - }); - - const summaryParts = [`${passedCount} passed`, `${failedCount} failed`]; - if (skippedCount > 0) summaryParts.push(`${skippedCount} skipped`); - const summaryText = `${summaryParts.join(", ")} out of ${report.steps.length} step${report.steps.length === 1 ? "" : "s"}`; - - const resultOutput = new CiResultOutput({ - version: VERSION, - status: report.status, - title: report.title, - duration_ms: totalDurationMs, - steps: stepResults, - artifacts: { - ...(effectiveVideoPath ? { video: effectiveVideoPath } : {}), - ...(artifacts.replayPath ? { replay: artifacts.replayPath } : {}), - ...(artifacts.screenshotPaths.length > 0 - ? { screenshots: [...artifacts.screenshotPaths] } - : {}), - }, - summary: summaryText, - }); - - const jsonString = JSON.stringify( - Schema.encodeSync(CiResultOutput)(resultOutput), - undefined, - 2, - ); - process.stdout.write(jsonString + "\n"); - } - - yield* Effect.promise(() => playSound()); - return report.status; - }).pipe( - Effect.withSpan("expect.session"), - Effect.provide(layerCli({ verbose: options.verbose, agent: options.agent })), - ), - ), + const sessionStartedAt = Date.now(); + yield* analytics.capture("session:started", { + mode: "headless", + skip_planning: false, + browser_headed: options.headed, + }); + + yield* analytics.capture("run:started", { plan_id: "direct" }); + + const executeStream = executor + .execute({ + changesFor: options.changesFor, + instruction: options.instruction, + isHeadless: !options.headed, + cookieImportProfiles, + }) + .pipe(Stream.runLast); + + const timeoutMs = Option.getOrUndefined(options.timeoutMs); + const executeWithTimeout = + timeoutMs !== undefined + ? executeStream.pipe( + Effect.timeoutOrElse({ + duration: `${timeoutMs} millis`, + onTimeout: () => + Effect.fail(new ExecutionTimeoutError({ timeoutMs })), + }) + ) + : executeStream; + + const finalExecuted = yield* executeWithTimeout.pipe( + Effect.flatMap((executedTestPlanOption) => + executedTestPlanOption.asEffect() + ) + ); + + const report = yield* reporter.report(finalExecuted); + + yield* analytics.capture("run:completed", { + plan_id: finalExecuted.id ?? "direct", + passed: report.passedStepCount, + failed: report.failedStepCount, + step_count: finalExecuted.steps.length, + file_count: 0, + duration_ms: Date.now() - sessionStartedAt, + }); + + yield* analytics.capture("session:ended", { + session_ms: Date.now() - sessionStartedAt, + }); + yield* analytics.flush; + + yield* outputReporter.onComplete(report); + yield* Effect.promise(() => playSound()); + yield* report.assertSuccess(); + }).pipe( + Effect.withSpan("expect.session"), + Effect.provide( + layerCli({ + verbose: options.verbose, + agent: options.agent, + reporter: options.reporter, + timeoutMs: Option.getOrUndefined(options.timeoutMs), + replayHost: options.replayHost, + testId: options.testId, + }) + ), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.die(cause) ), - ).then((status) => { - process.exit(status === "passed" ? 0 : 1); - }); + Effect.tapCause((cause) => Effect.logFatal(cause)), + Effect.runPromise + ); diff --git a/apps/cli/src/utils/step-elapsed.ts b/apps/cli/src/utils/step-elapsed.ts deleted file mode 100644 index 6384b448d..000000000 --- a/apps/cli/src/utils/step-elapsed.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DateTime, Option } from "effect"; -import type { TestPlanStep } from "@expect/shared/models"; - -export const getStepElapsedMs = (step: TestPlanStep): number | undefined => { - if (Option.isNone(step.startedAt) || Option.isNone(step.endedAt)) return undefined; - return DateTime.toEpochMillis(step.endedAt.value) - DateTime.toEpochMillis(step.startedAt.value); -}; - -export const getTotalElapsedMs = (steps: readonly TestPlanStep[]): number => { - let totalMs = 0; - for (const step of steps) { - const elapsed = getStepElapsedMs(step); - if (elapsed !== undefined) totalMs += elapsed; - } - return totalMs; -}; diff --git a/apps/cli/tests/add-skill.test.ts b/apps/cli/tests/add-skill.test.ts index 799752b26..09fabaae6 100644 --- a/apps/cli/tests/add-skill.test.ts +++ b/apps/cli/tests/add-skill.test.ts @@ -1,5 +1,5 @@ -import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, it, beforeEach, afterEach } from "vite-plus/test"; import { extractTarEntries, readNullTerminated } from "../src/commands/add-skill"; @@ -60,11 +60,11 @@ describe("extractTarEntries", () => { let destDir: string; beforeEach(() => { - destDir = mkdtempSync(join(tmpdir(), "tar-test-")); + destDir = fs.mkdtempSync(path.join(tmpdir(), "tar-test-")); }); afterEach(() => { - rmSync(destDir, { recursive: true, force: true }); + fs.rmSync(destDir, { recursive: true, force: true }); }); it("extracts files matching the prefix", () => { @@ -75,8 +75,8 @@ describe("extractTarEntries", () => { extractTarEntries(tar, "repo-main/packages/skill/", destDir); - expect(readFileSync(join(destDir, "README.md"), "utf8")).toBe("# Skill"); - expect(readFileSync(join(destDir, "SKILL.md"), "utf8")).toBe("skill content"); + expect(fs.readFileSync(path.join(destDir, "README.md"), "utf8")).toBe("# Skill"); + expect(fs.readFileSync(path.join(destDir, "SKILL.md"), "utf8")).toBe("skill content"); }); it("skips files outside the prefix", () => { @@ -87,7 +87,7 @@ describe("extractTarEntries", () => { extractTarEntries(tar, "repo-main/packages/skill/", destDir); - const files = readdirSync(destDir, { recursive: true }); + const files = fs.readdirSync(destDir, { recursive: true }); expect(files).toEqual(["keep.txt"]); }); @@ -96,13 +96,13 @@ describe("extractTarEntries", () => { extractTarEntries(tar, "prefix/", destDir); - expect(readFileSync(join(destDir, "sub", "dir", "file.txt"), "utf8")).toBe("nested"); + expect(fs.readFileSync(path.join(destDir, "sub", "dir", "file.txt"), "utf8")).toBe("nested"); }); it("handles empty tar archive", () => { const tar = Buffer.alloc(TAR_HEADER_SIZE * 2); extractTarEntries(tar, "prefix/", destDir); - expect(readdirSync(destDir)).toEqual([]); + expect(fs.readdirSync(destDir)).toEqual([]); }); }); diff --git a/apps/cli/tests/artifact-proxy.test.ts b/apps/cli/tests/artifact-proxy.test.ts new file mode 100644 index 000000000..8054b416d --- /dev/null +++ b/apps/cli/tests/artifact-proxy.test.ts @@ -0,0 +1,94 @@ +import { describe, it, assert } from "vite-plus/test"; +import { Effect, Layer } from "effect"; +import { CurrentPlanId, PlanId } from "@expect/shared/models"; +import { DEFAULT_REPLAY_HOST, LIVE_VIEWER_STATIC_PORT } from "@expect/shared"; +import { layerArtifactViewerProxy } from "../src/artifact-server"; +import { ReplayHost } from "../src/replay-host"; +import { Headers } from "effect/unstable/http"; + +const liveLayer = layerArtifactViewerProxy.pipe( + Layer.provide(Layer.succeed(ReplayHost, DEFAULT_REPLAY_HOST)), + Layer.provide(Layer.succeed(CurrentPlanId, PlanId.makeUnsafe("test-live"))), +); + +const proxyUrl = `http://localhost:${LIVE_VIEWER_STATIC_PORT}`; + +const runLive = (effect: Effect.Effect) => + effect.pipe(Effect.scoped, Effect.provide(liveLayer), Effect.runPromise); + +const PER_REQUEST_HEADERS = new Set([ + "date", + "age", + "x-vercel-id", + "connection", + "keep-alive", + "content-encoding", + "content-length", + "transfer-encoding", +]); + +const headersToRecord = (headers: Headers): Record => { + const record: Record = {}; + headers.forEach((value, key) => { + if (!PER_REQUEST_HEADERS.has(key)) { + record[key] = value; + } + }); + return record; +}; + +describe("live viewer proxy (production expect.dev)", () => { + it( + "replay page: proxy response exactly matches direct response", + () => + runLive( + Effect.gen(function* () { + const [direct, proxied] = yield* Effect.promise(() => + Promise.all([ + globalThis.fetch("https://www.expect.dev/replay"), + globalThis.fetch(`${proxyUrl}/replay`), + ]), + ); + + assert.strictEqual(proxied.status, direct.status); + + const [directBody, proxiedBody] = yield* Effect.promise(() => + Promise.all([direct.text(), proxied.text()]), + ); + assert.strictEqual(proxiedBody, directBody); + + const directHeaders = headersToRecord(direct.headers); + const proxiedHeaders = headersToRecord(proxied.headers); + assert.deepStrictEqual(proxiedHeaders, directHeaders); + }), + ), + 30_000, + ); + + it( + "homepage: proxy response exactly matches direct response", + () => + runLive( + Effect.gen(function* () { + const [direct, proxied] = yield* Effect.promise(() => + Promise.all([ + globalThis.fetch("https://www.expect.dev/"), + globalThis.fetch(`${proxyUrl}/`), + ]), + ); + + assert.strictEqual(proxied.status, direct.status); + + const [directBody, proxiedBody] = yield* Effect.promise(() => + Promise.all([direct.text(), proxied.text()]), + ); + assert.strictEqual(proxiedBody, directBody); + + const directHeaders = headersToRecord(direct.headers); + const proxiedHeaders = headersToRecord(proxied.headers); + assert.deepStrictEqual(proxiedHeaders, directHeaders); + }), + ), + 30_000, + ); +}); diff --git a/apps/cli/tests/load-replay-events.test.ts b/apps/cli/tests/load-replay-events.test.ts index 2900dba08..865f13ca6 100644 --- a/apps/cli/tests/load-replay-events.test.ts +++ b/apps/cli/tests/load-replay-events.test.ts @@ -1,7 +1,7 @@ import { createServer, type Server } from "node:http"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import { Effect } from "effect"; import { afterEach, describe, expect, it } from "vite-plus/test"; @@ -57,19 +57,19 @@ describe("loadReplayEvents", () => { } if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); tempDir = undefined; } }); it("prefers finalized replay artifacts over the live endpoint", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); - const replaySessionPath = join(tempDir, "session.ndjson"); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); + const replaySessionPath = path.join(tempDir, "session.ndjson"); const finalizedEvents = [ { type: 2, timestamp: 1000, data: { href: "https://expect.dev" } }, { type: 3, timestamp: 2000, data: { source: 0 } }, ]; - writeFileSync( + fs.writeFileSync( replaySessionPath, finalizedEvents.map((event) => JSON.stringify(event)).join("\n") + "\n", ); diff --git a/apps/cli/vite.config.ts b/apps/cli/vite.config.ts index 06e523158..d29d0a10a 100644 --- a/apps/cli/vite.config.ts +++ b/apps/cli/vite.config.ts @@ -1,6 +1,6 @@ import { createRequire } from "node:module"; -import { readFileSync, realpathSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import type { Plugin } from "rolldown"; import { defineConfig } from "vite-plus"; import { reactCompilerPlugin } from "./react-compiler-plugin"; @@ -25,9 +25,9 @@ const findPackageDir = (packageName: string): string | undefined => { if (!searchPaths) return undefined; for (const searchPath of searchPaths) { - const candidate = join(searchPath, packageName); + const candidate = path.join(searchPath, packageName); try { - realpathSync(candidate); + fs.realpathSync(candidate); return candidate; } catch { continue; @@ -52,9 +52,9 @@ const buildExpectSubpathMap = (): Record => { const packageDir = findPackageDir(packageName); if (!packageDir) continue; - const packageJsonPath = join(packageDir, "package.json"); + const packageJsonPath = path.join(packageDir, "package.json"); const packageJson: { exports?: Record } = JSON.parse( - readFileSync(realpathSync(packageJsonPath), "utf8"), + fs.readFileSync(fs.realpathSync(packageJsonPath), "utf8"), ); if (!packageJson.exports) continue; @@ -64,7 +64,7 @@ const buildExpectSubpathMap = (): Record => { const specifier = `${packageName}/${subpath.slice(2)}`; const file = resolveExportFile(packageJson.exports[subpath]); if (file) { - map[specifier] = join(realpathSync(packageDir), distToSource(file)); + map[specifier] = path.join(fs.realpathSync(packageDir), distToSource(file)); } } } diff --git a/apps/website/app/llms.txt/route.ts b/apps/website/app/llms.txt/route.ts index 4dae41ea7..615af0d5f 100644 --- a/apps/website/app/llms.txt/route.ts +++ b/apps/website/app/llms.txt/route.ts @@ -2,6 +2,8 @@ import { readFileSync } from "fs"; import { NextResponse } from "next/server"; import { join } from "path"; +export const dynamic = "force-static"; + const skill = readFileSync( join(process.cwd(), "..", "..", "packages", "expect-skill", "SKILL.md"), "utf-8", diff --git a/apps/website/app/replay/page.tsx b/apps/website/app/replay/page.tsx index 7aa636cd6..628815748 100644 --- a/apps/website/app/replay/page.tsx +++ b/apps/website/app/replay/page.tsx @@ -1,13 +1,16 @@ "use client"; import { Suspense, useEffect, useRef } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; +import { DateTime } from "effect"; import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; import type { eventWithTime } from "@posthog/rrweb"; +import { type Artifact, ExecutedTestPlan, type PlanId } from "@expect/shared/models"; import { ReplayViewer } from "@/components/replay/replay-viewer"; import type { ViewerRunState } from "@/lib/replay-types"; import { DEMO_TRACE } from "@/lib/demo-trace"; import { DEMO_EVENTS } from "@/lib/demo-events"; +import { fetchAllArtifacts } from "@/lib/replay/fetch-artifacts"; const POLL_INTERVAL_MS = 500; const EMPTY_EVENTS: eventWithTime[] = []; @@ -18,36 +21,56 @@ const queryClient = new QueryClient({ const NoEvents = () => ; -const fetchLatestEvents = async (): Promise => { - const response = await fetch("/latest.json"); - if (!response.ok) return []; - return response.json(); -}; +const deriveEvents = (artifacts: readonly Artifact[]): eventWithTime[] => + artifacts.filter((a) => a._tag === "RrwebEvent").map((a) => a.event as eventWithTime); -const fetchSteps = async (): Promise => { - const response = await fetch("/steps"); - if (!response.ok) return { title: "", status: "running", summary: undefined, steps: [] }; - return response.json(); +const deriveSteps = (artifacts: readonly Artifact[]): ViewerRunState | undefined => { + let executedPlan: ExecutedTestPlan | undefined; + for (const artifact of artifacts) { + if (artifact._tag === "InitialPlan") { + executedPlan = new ExecutedTestPlan({ ...artifact.plan, events: [] }); + } else if (artifact._tag === "SessionUpdate" && executedPlan) { + executedPlan = executedPlan.addEvent(artifact.update); + } + } + if (!executedPlan) return undefined; + const runFinished = executedPlan.events.find((event) => event._tag === "RunFinished"); + const done = executedPlan.hasRunFinished || artifacts.some((a) => a._tag === "Done"); + return { + title: executedPlan.title, + status: runFinished ? runFinished.status : "running", + summary: runFinished ? runFinished.summary : undefined, + done, + steps: executedPlan.steps.map((step) => ({ + stepId: step.id, + title: step.title, + status: step.status, + summary: step.summary._tag === "Some" ? step.summary.value : undefined, + startedAtMs: + step.startedAt._tag === "Some" + ? Number(DateTime.toEpochMillis(step.startedAt.value)) + : undefined, + endedAtMs: + step.endedAt._tag === "Some" + ? Number(DateTime.toEpochMillis(step.endedAt.value)) + : undefined, + })), + }; }; -const LiveMode = () => { +const LiveMode = ({ testId }: { testId: string }) => { const addEventsRef = useRef<((newEvents: eventWithTime[]) => void) | undefined>(undefined); const prevEventCountRef = useRef(0); - const eventsQuery = useQuery({ - queryKey: ["replay-events"], - queryFn: fetchLatestEvents, - refetchInterval: POLL_INTERVAL_MS, - }); - - const stepsQuery = useQuery({ - queryKey: ["replay-steps"], - queryFn: fetchSteps, + const artifactsQuery = useQuery({ + queryKey: ["replay-artifacts", testId], + queryFn: () => fetchAllArtifacts(testId as PlanId), refetchInterval: POLL_INTERVAL_MS, }); - const events = eventsQuery.data ?? EMPTY_EVENTS; - const steps = stepsQuery.data; + const artifacts = artifactsQuery.data ?? []; + const events = deriveEvents(artifacts); + const steps = deriveSteps(artifacts); const isRunning = !steps || (steps.status === "running" && !steps.done); useEffect(() => { @@ -55,7 +78,7 @@ const LiveMode = () => { const newEvents = events.slice(prevEventCountRef.current); prevEventCountRef.current = events.length; addEventsRef.current?.(newEvents); - }, [events]); + }, [events.length]); const handleAddEventsRef = (handler: (newEvents: eventWithTime[]) => void) => { addEventsRef.current = handler; @@ -78,22 +101,15 @@ const DemoMode = () => { const ReplayPageInner = () => { const searchParams = useSearchParams(); - const router = useRouter(); - const isLive = searchParams.get("live") === "true"; const isDemo = searchParams.get("demo") === "true"; - - useEffect(() => { - if (!isLive && !isDemo) { - router.replace("/replay?live=true"); - } - }, [isLive, isDemo, router]); + const testId = searchParams.get("testId"); if (isDemo) { return ; } - if (isLive) { - return ; + if (testId) { + return ; } return ; diff --git a/apps/website/components/replay/replay-viewer.tsx b/apps/website/components/replay/replay-viewer.tsx index a1235dacf..656a81392 100644 --- a/apps/website/components/replay/replay-viewer.tsx +++ b/apps/website/components/replay/replay-viewer.tsx @@ -4,8 +4,18 @@ import { Calligraph } from "calligraph"; import { useEffect, useRef, useState, type CSSProperties } from "react"; import type { eventWithTime } from "@posthog/rrweb"; import type { Replayer } from "@posthog/rrweb"; -import { animate, AnimatePresence, motion, useMotionValue, useTransform } from "motion/react"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + animate, + AnimatePresence, + motion, + useMotionValue, + useTransform, +} from "motion/react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { formatTime } from "@/lib/format-time"; import { useMountEffect } from "@/hooks/use-mount-effect"; @@ -23,20 +33,25 @@ const IDLE_SPEED_TIERS = [ { afterMs: 20000, speed: 8 }, { afterMs: 40000, speed: 16 }, ] as const; -const LIVE_PLAYBACK_BAR_SHADOW = "color(display-p3 0.281 0.281 0.281 / 22%) 0px 0px 0px 1px"; -const LIVE_PLAYBACK_BAR_BUTTON_SHADOW = "color(display-p3 0.847 0.847 0.847) 0px 0px 0px 0.5px"; +const LIVE_PLAYBACK_BAR_SHADOW = + "color(display-p3 0.281 0.281 0.281 / 22%) 0px 0px 0px 1px"; +const LIVE_PLAYBACK_BAR_BUTTON_SHADOW = + "color(display-p3 0.847 0.847 0.847) 0px 0px 0px 0.5px"; const LIVE_PLAYBACK_BAR_MARKER_INTERVAL_MS = 10_000; -const LIVE_PASSED_STEP_MARKER_OUTLINE = "2px solid color(display-p3 0.249 0.701 0.193 / 30%)"; +const LIVE_PASSED_STEP_MARKER_OUTLINE = + "2px solid color(display-p3 0.249 0.701 0.193 / 30%)"; const LIVE_PASSED_STEP_MARKER_BACKGROUND_IMAGE = "linear-gradient(in oklab 180deg, oklab(66.4% -0.197 0.139) 0%, oklab(72.7% -0.252 0.178) 100%)"; const LIVE_FAILED_STEP_MARKER_OUTLINE = "2px solid #FC272F4D"; const LIVE_FAILED_STEP_MARKER_BACKGROUND_IMAGE = "linear-gradient(in oklab 180deg, oklab(63.6% 0.216 0.107) 0%, oklab(67.1% 0.194 0.096) 100%)"; -const LIVE_PLAYBACK_PROGRESS_SHADOW = "color(display-p3 0.615 0.615 0.615 / 20%) 0px 0px 3px"; +const LIVE_PLAYBACK_PROGRESS_SHADOW = + "color(display-p3 0.615 0.615 0.615 / 20%) 0px 0px 3px"; const LIVE_PLAYBACK_PROGRESS_RIGHT_EDGE_SHADOW = "inset -1px 0px 0px color(display-p3 0.725 0.725 0.725 / 80%)"; const LIVE_PLAYBACK_PROGRESS_RIGHT_EDGE_HIDE_PERCENT = 99; -const VIEWER_SHELL_SHADOW = "color(display-p3 0.788 0.788 0.788 / 20%) 0px 2px 3px"; +const VIEWER_SHELL_SHADOW = + "color(display-p3 0.788 0.788 0.788 / 20%) 0px 2px 3px"; const CONTROL_FONT_FAMILY = '"SF Pro Display", "SFProDisplay-Medium", "Inter Variable", system-ui, sans-serif'; const REPLAY_BACKDROP_STYLE = { @@ -49,7 +64,8 @@ const PAPER_TIME_LENGTH = 5; const LIVE_PLAYBACK_BAR_SURFACE_COLOR = "color(display-p3 0.938 0.938 0.938)"; const LIVE_PLAYBACK_PROGRESS_BACKGROUND_IMAGE = "linear-gradient(in oklab 180deg, oklab(100% 0 0) 0%, oklab(100% 0 0 / 61%) 100%)"; -const ACTIVE_STEP_CARD_SHADOW = "color(display-p3 0.847 0.847 0.847) 0px 0px 0px 0.5px"; +const ACTIVE_STEP_CARD_SHADOW = + "color(display-p3 0.847 0.847 0.847) 0px 0px 0px 0.5px"; const ACTIVE_STEP_CARD_TRANSITION = { type: "tween", duration: 0.16, @@ -113,7 +129,10 @@ const extractReplayActions = (events: eventWithTime[]): ReplayAction[] => { if (event.type === RRWEB_EVENT_META && typeof data.href === "string") { try { const url = new URL(data.href); - const displayPath = url.pathname === "/" ? url.hostname : `${url.hostname}${url.pathname}`; + const displayPath = + url.pathname === "/" + ? url.hostname + : `${url.hostname}${url.pathname}`; actions.push({ id: `nav-${event.timestamp}`, label: `Navigated to ${displayPath}`, @@ -131,7 +150,11 @@ const extractReplayActions = (events: eventWithTime[]): ReplayAction[] => { const interactionType = data.type as number; const label = MOUSE_INTERACTION_LABELS[interactionType]; if (label) { - actions.push({ id: `mouse-${interactionType}-${event.timestamp}`, label, relativeMs }); + actions.push({ + id: `mouse-${interactionType}-${event.timestamp}`, + label, + relativeMs, + }); } continue; } @@ -139,10 +162,12 @@ const extractReplayActions = (events: eventWithTime[]): ReplayAction[] => { if (data.source === RRWEB_SOURCE_INPUT) { if (event.timestamp - lastInputTs > INPUT_DEBOUNCE_MS) { const rawText = typeof data.text === "string" ? data.text : ""; - const displayText = rawText.length > 30 ? `${rawText.slice(0, 30)}…` : rawText; + const displayText = + rawText.length > 30 ? `${rawText.slice(0, 30)}…` : rawText; actions.push({ id: `input-${event.timestamp}`, - label: displayText.length > 0 ? `Typed "${displayText}"` : "Typed input", + label: + displayText.length > 0 ? `Typed "${displayText}"` : "Typed input", relativeMs: relativeMs + INPUT_ACTION_OFFSET_MS, }); } @@ -152,7 +177,11 @@ const extractReplayActions = (events: eventWithTime[]): ReplayAction[] => { if (data.source === RRWEB_SOURCE_SCROLL) { if (event.timestamp - lastScrollTs > SCROLL_DEBOUNCE_MS) { - actions.push({ id: `scroll-${event.timestamp}`, label: "Scrolled", relativeMs }); + actions.push({ + id: `scroll-${event.timestamp}`, + label: "Scrolled", + relativeMs, + }); } lastScrollTs = event.timestamp; continue; @@ -175,8 +204,8 @@ const extractReplayActions = (events: eventWithTime[]): ReplayAction[] => { mediaType === RRWEB_MEDIA_PLAY ? "Played media" : mediaType === RRWEB_MEDIA_PAUSE - ? "Paused media" - : undefined; + ? "Paused media" + : undefined; if (label) { actions.push({ id: `media-${event.timestamp}`, label, relativeMs }); } @@ -184,7 +213,11 @@ const extractReplayActions = (events: eventWithTime[]): ReplayAction[] => { } if (data.source === RRWEB_SOURCE_DRAG) { - actions.push({ id: `drag-${event.timestamp}`, label: "Dragged", relativeMs }); + actions.push({ + id: `drag-${event.timestamp}`, + label: "Dragged", + relativeMs, + }); continue; } } @@ -195,7 +228,10 @@ const extractReplayActions = (events: eventWithTime[]): ReplayAction[] => { const getReplayDuration = (replayEvents: eventWithTime[]) => { if (replayEvents.length < 2) return 0; - return Math.max(replayEvents[replayEvents.length - 1].timestamp - replayEvents[0].timestamp, 0); + return Math.max( + replayEvents[replayEvents.length - 1].timestamp - replayEvents[0].timestamp, + 0 + ); }; const isTransparentBackground = (backgroundColor: string) => @@ -203,7 +239,7 @@ const isTransparentBackground = (backgroundColor: string) => const getElementBackground = ( element: Element | null | undefined, - frameWindow: Window, + frameWindow: Window ): string | undefined => { if (!element || element.nodeType !== Node.ELEMENT_NODE) return undefined; @@ -233,7 +269,10 @@ const getReplayFrameBackground = (replayer: Replayer | undefined) => { ]; for (const samplePoint of samplePoints) { - const sampleElement = frameDocument.elementFromPoint(samplePoint.x, samplePoint.y); + const sampleElement = frameDocument.elementFromPoint( + samplePoint.x, + samplePoint.y + ); const background = getElementBackground(sampleElement, frameWindow); if (background) { return background; @@ -243,25 +282,33 @@ const getReplayFrameBackground = (replayer: Replayer | undefined) => { return getElementBackground(frameDocument.body, frameWindow); }; -const formatPaperTime = (timeMs: number) => formatTime(timeMs).padStart(PAPER_TIME_LENGTH, "0"); +const formatPaperTime = (timeMs: number) => + formatTime(timeMs).padStart(PAPER_TIME_LENGTH, "0"); const getStepRelativeTime = (step: ViewerStepEvent, replayStartMs: number) => { const startMs = - step.startedAtMs !== undefined ? Math.max(0, step.startedAtMs - replayStartMs) : undefined; + step.startedAtMs !== undefined + ? Math.max(0, step.startedAtMs - replayStartMs) + : undefined; const endMs = - step.endedAtMs !== undefined ? Math.max(0, step.endedAtMs - replayStartMs) : undefined; + step.endedAtMs !== undefined + ? Math.max(0, step.endedAtMs - replayStartMs) + : undefined; return { startMs, endMs }; }; const getPlaybackStepIndex = ( stepEvents: readonly ViewerStepEvent[] | undefined, replayStartMs: number, - currentTime: number, + currentTime: number ) => { if (!stepEvents || stepEvents.length === 0) return -1; for (let index = stepEvents.length - 1; index >= 0; index--) { - const { startMs, endMs } = getStepRelativeTime(stepEvents[index], replayStartMs); + const { startMs, endMs } = getStepRelativeTime( + stepEvents[index], + replayStartMs + ); const stepTimeMs = startMs ?? endMs; if (stepTimeMs === undefined) continue; if (stepTimeMs <= currentTime) { @@ -275,15 +322,22 @@ const getPlaybackStepIndex = ( const getPlaybackBarRubberBandStretch = ( playbackBarRect: DOMRect, clientX: number, - direction: -1 | 1, + direction: -1 | 1 ) => { const distancePastEdge = - direction < 0 ? playbackBarRect.left - clientX : clientX - playbackBarRect.right; - const overflowPx = Math.max(0, distancePastEdge - PLAYBACK_BAR_RUBBER_BAND_DEAD_ZONE_PX); + direction < 0 + ? playbackBarRect.left - clientX + : clientX - playbackBarRect.right; + const overflowPx = Math.max( + 0, + distancePastEdge - PLAYBACK_BAR_RUBBER_BAND_DEAD_ZONE_PX + ); return ( direction * PLAYBACK_BAR_RUBBER_BAND_MAX_STRETCH_PX * - Math.sqrt(Math.min(overflowPx / PLAYBACK_BAR_RUBBER_BAND_CURSOR_RANGE_PX, 1)) + Math.sqrt( + Math.min(overflowPx / PLAYBACK_BAR_RUBBER_BAND_CURSOR_RANGE_PX, 1) + ) ); }; @@ -292,7 +346,12 @@ interface ControlIconProps { } const PlayIcon = ({ className }: ControlIconProps) => ( - + ( ); const FullscreenIcon = ({ className }: ControlIconProps) => ( - + (1); - const [browserFrameBackground, setBrowserFrameBackground] = useState( - undefined, - ); + const [browserFrameBackground, setBrowserFrameBackground] = useState< + string | undefined + >(undefined); const playbackBarRef = useRef(null); const replayRef = useRef(null); const viewerShellRef = useRef(null); @@ -360,8 +424,12 @@ export const ReplayViewer = ({ const liveRef = useRef(live); liveRef.current = live; const playPauseRef = useRef<(() => Promise) | undefined>(undefined); - const stepNavigationRef = useRef<((direction: "up" | "down") => void) | undefined>(undefined); - const stepJumpRef = useRef<((stepNumber: number) => void) | undefined>(undefined); + const stepNavigationRef = useRef< + ((direction: "up" | "down") => void) | undefined + >(undefined); + const stepJumpRef = useRef<((stepNumber: number) => void) | undefined>( + undefined + ); const isIdleSpeedRef = useRef(false); const userSpeedRef = useRef<(typeof SPEEDS)[number]>(1); const lastCursorPosRef = useRef(""); @@ -371,15 +439,18 @@ export const ReplayViewer = ({ const stepListDraggedStepIdRef = useRef(undefined); const playbackBarRectRef = useRef(undefined); const playbackBarPointerActiveRef = useRef(false); - const playbackBarRubberResetRef = useRef<{ stop: () => void } | undefined>(undefined); + const playbackBarRubberResetRef = useRef<{ stop: () => void } | undefined>( + undefined + ); const browserFrameBackgroundRef = useRef(undefined); const playbackBarRubberStretchPx = useMotionValue(0); const playbackBarRubberBandWidth = useTransform( playbackBarRubberStretchPx, - (stretchPx) => `calc(100% + ${Math.abs(stretchPx)}px)`, + (stretchPx) => `calc(100% + ${Math.abs(stretchPx)}px)` ); - const playbackBarRubberBandX = useTransform(playbackBarRubberStretchPx, (stretchPx) => - stretchPx < 0 ? stretchPx : 0, + const playbackBarRubberBandX = useTransform( + playbackBarRubberStretchPx, + (stretchPx) => (stretchPx < 0 ? stretchPx : 0) ); const destroyReplay = () => { @@ -512,7 +583,8 @@ export const ReplayViewer = ({ } const eventTarget = event.target; - const targetElement = eventTarget instanceof HTMLElement ? eventTarget : null; + const targetElement = + eventTarget instanceof HTMLElement ? eventTarget : null; const targetUsesArrowKeys = targetElement?.isContentEditable || targetElement?.tagName === "INPUT" || @@ -575,7 +647,9 @@ export const ReplayViewer = ({ if (!replayRef.current) return undefined; const replayContainer = replayRef.current; - const wrapper = replayContainer.querySelector(".replayer-wrapper") as HTMLElement | undefined; + const wrapper = replayContainer.querySelector(".replayer-wrapper") as + | HTMLElement + | undefined; if (!wrapper) return undefined; const iframe = wrapper.querySelector("iframe"); @@ -587,9 +661,18 @@ export const ReplayViewer = ({ const containerWidth = replayContainer.clientWidth; const containerHeight = replayContainer.clientHeight; - if (!recordedWidth || !recordedHeight || !containerWidth || !containerHeight) return; + if ( + !recordedWidth || + !recordedHeight || + !containerWidth || + !containerHeight + ) + return; - const fitScale = Math.min(containerWidth / recordedWidth, containerHeight / recordedHeight); + const fitScale = Math.min( + containerWidth / recordedWidth, + containerHeight / recordedHeight + ); const scaledWidth = recordedWidth * fitScale; const scaledHeight = recordedHeight * fitScale; @@ -616,7 +699,9 @@ export const ReplayViewer = ({ attributeFilter: ["width", "height"], }); - const cursorEl = wrapper.querySelector(".replayer-mouse") as HTMLElement | undefined; + const cursorEl = wrapper.querySelector(".replayer-mouse") as + | HTMLElement + | undefined; if (cursorEl) { cleanupIdleObserverRef.current = setupIdleSpeedObserver(cursorEl); } @@ -638,7 +723,8 @@ export const ReplayViewer = ({ clearInterval(timerRef.current); setPlaying(false); } else { - const resumeTime = !liveRef.current && currentTime >= replayDuration ? 0 : currentTime; + const resumeTime = + !liveRef.current && currentTime >= replayDuration ? 0 : currentTime; replayerRef.current.play(resumeTime); setCurrentTime(resumeTime); startTimer(); @@ -664,11 +750,16 @@ export const ReplayViewer = ({ if (replayIframe) { const currentSandbox = replayIframe.getAttribute("sandbox") ?? ""; if (!currentSandbox.includes("allow-scripts")) { - replayIframe.setAttribute("sandbox", `${currentSandbox} allow-scripts`.trim()); + replayIframe.setAttribute( + "sandbox", + `${currentSandbox} allow-scripts`.trim() + ); } } - const startTime = liveRef.current ? replayDuration : Math.min(currentTime, replayDuration); + const startTime = liveRef.current + ? replayDuration + : Math.min(currentTime, replayDuration); setCurrentTime(startTime); replayer.play(startTime); syncBrowserFrameBackground(); @@ -709,26 +800,35 @@ export const ReplayViewer = ({ if (clientX < playbackBarRect.left) { playbackBarRubberStretchPx.jump( - getPlaybackBarRubberBandStretch(playbackBarRect, clientX, -1), + getPlaybackBarRubberBandStretch(playbackBarRect, clientX, -1) ); return; } if (clientX > playbackBarRect.right) { - playbackBarRubberStretchPx.jump(getPlaybackBarRubberBandStretch(playbackBarRect, clientX, 1)); + playbackBarRubberStretchPx.jump( + getPlaybackBarRubberBandStretch(playbackBarRect, clientX, 1) + ); return; } playbackBarRubberStretchPx.jump(0); }; - const finishPlaybackBarPointerInteraction = (target?: HTMLInputElement, pointerId?: number) => { + const finishPlaybackBarPointerInteraction = ( + target?: HTMLInputElement, + pointerId?: number + ) => { if (!playbackBarPointerActiveRef.current) return; playbackBarPointerActiveRef.current = false; playbackBarRectRef.current = undefined; - if (target && pointerId !== undefined && target.hasPointerCapture(pointerId)) { + if ( + target && + pointerId !== undefined && + target.hasPointerCapture(pointerId) + ) { target.releasePointerCapture(pointerId); } @@ -740,11 +840,13 @@ export const ReplayViewer = ({ playbackBarRubberResetRef.current = animate( playbackBarRubberStretchPx, 0, - PLAYBACK_BAR_RUBBER_BAND_TRANSITION, + PLAYBACK_BAR_RUBBER_BAND_TRANSITION ); }; - const handlePlaybackBarPointerDown = (event: React.PointerEvent) => { + const handlePlaybackBarPointerDown = ( + event: React.PointerEvent + ) => { const playbackBarRect = playbackBarRef.current?.getBoundingClientRect(); if (!playbackBarRect) return; @@ -756,16 +858,22 @@ export const ReplayViewer = ({ updatePlaybackBarRubberBand(event.clientX); }; - const handlePlaybackBarPointerMove = (event: React.PointerEvent) => { + const handlePlaybackBarPointerMove = ( + event: React.PointerEvent + ) => { if (!playbackBarPointerActiveRef.current) return; updatePlaybackBarRubberBand(event.clientX); }; - const handlePlaybackBarPointerUp = (event: React.PointerEvent) => { + const handlePlaybackBarPointerUp = ( + event: React.PointerEvent + ) => { finishPlaybackBarPointerInteraction(event.currentTarget, event.pointerId); }; - const handlePlaybackBarPointerCancel = (event: React.PointerEvent) => { + const handlePlaybackBarPointerCancel = ( + event: React.PointerEvent + ) => { finishPlaybackBarPointerInteraction(event.currentTarget, event.pointerId); }; @@ -799,17 +907,25 @@ export const ReplayViewer = ({ }; const replayStartMs = - events.length > 0 ? events[0].timestamp : (steps?.steps[0]?.startedAtMs ?? 0); + events.length > 0 ? events[0].timestamp : steps?.steps[0]?.startedAtMs ?? 0; const hasEvents = events.length > 1; const totalTime = getReplayDuration(events); const replayActions = extractReplayActions(events); - const stepActions: ReplayAction[] = (steps?.steps ?? []).flatMap((step, index) => { - if (step.startedAtMs === undefined) return []; - const relativeMs = Math.max(0, step.startedAtMs - replayStartMs); - return [{ id: `step-${step.stepId}`, label: `Step ${index + 1}: ${step.title}`, relativeMs }]; - }); + const stepActions: ReplayAction[] = (steps?.steps ?? []).flatMap( + (step, index) => { + if (step.startedAtMs === undefined) return []; + const relativeMs = Math.max(0, step.startedAtMs - replayStartMs); + return [ + { + id: `step-${step.stepId}`, + label: `Step ${index + 1}: ${step.title}`, + relativeMs, + }, + ]; + } + ); const allActions = [...replayActions, ...stepActions].sort( - (left, right) => left.relativeMs - right.relativeMs, + (left, right) => left.relativeMs - right.relativeMs ); const visibleActions = allActions .filter((action) => { @@ -844,8 +960,13 @@ export const ReplayViewer = ({ ? LIVE_PLAYBACK_PROGRESS_SHADOW : `${LIVE_PLAYBACK_PROGRESS_RIGHT_EDGE_SHADOW}, ${LIVE_PLAYBACK_PROGRESS_SHADOW}`; - const activeStepIndex = getPlaybackStepIndex(steps?.steps, replayStartMs, currentTime); - const currentStep = steps && activeStepIndex >= 0 ? steps.steps[activeStepIndex] : undefined; + const activeStepIndex = getPlaybackStepIndex( + steps?.steps, + replayStartMs, + currentTime + ); + const currentStep = + steps && activeStepIndex >= 0 ? steps.steps[activeStepIndex] : undefined; const currentStepLabel = currentStep ? `Step ${activeStepIndex + 1}` : ""; const currentStepTitle = currentStep?.title ?? ""; const stepList = @@ -863,19 +984,19 @@ export const ReplayViewer = ({ step.status === "failed" ? "bg-[color(display-p3_0.988_0.153_0.184)]" : step.status === "passed" - ? "bg-[color(display-p3_0.249_0.701_0.193)]" - : "bg-[color(display-p3_0.787_0.787_0.787)]", + ? "bg-[color(display-p3_0.249_0.701_0.193)]" + : "bg-[color(display-p3_0.787_0.787_0.787)]", }; }) ?? []; stepNavigationRef.current = (direction) => { if (!hasEvents) return; const navigableStepIndices = stepList.flatMap((step, index) => - step.timeMs !== undefined ? [index] : [], + step.timeMs !== undefined ? [index] : [] ); if (navigableStepIndices.length === 0) return; const currentNavigablePosition = navigableStepIndices.findIndex( - (index) => index === activeStepIndex, + (index) => index === activeStepIndex ); if (currentNavigablePosition === -1) { if (direction === "down") { @@ -891,7 +1012,7 @@ export const ReplayViewer = ({ const stepOffset = direction === "down" ? 1 : -1; const nextPosition = Math.min( Math.max(currentNavigablePosition + stepOffset, 0), - navigableStepIndices.length - 1, + navigableStepIndices.length - 1 ); const nextStepIndex = navigableStepIndices[nextPosition]; if (nextStepIndex === activeStepIndex) return; @@ -912,7 +1033,7 @@ export const ReplayViewer = ({ if (!(element instanceof HTMLElement)) return false; const stepButton = element.closest( - "[data-replay-step-id][data-replay-step-time-ms]", + "[data-replay-step-id][data-replay-step-time-ms]" ); const stepId = stepButton?.dataset.replayStepId; const stepTimeMs = stepButton?.dataset.replayStepTimeMs; @@ -923,17 +1044,26 @@ export const ReplayViewer = ({ seekTo(Number(stepTimeMs)); return true; }; - const finishStepListPointerInteraction = (target?: HTMLDivElement, pointerId?: number) => { + const finishStepListPointerInteraction = ( + target?: HTMLDivElement, + pointerId?: number + ) => { if (!stepListPointerActiveRef.current) return; stepListPointerActiveRef.current = false; stepListDraggedStepIdRef.current = undefined; - if (target && pointerId !== undefined && target.hasPointerCapture(pointerId)) { + if ( + target && + pointerId !== undefined && + target.hasPointerCapture(pointerId) + ) { target.releasePointerCapture(pointerId); } }; - const handleStepListPointerDown = (event: React.PointerEvent) => { + const handleStepListPointerDown = ( + event: React.PointerEvent + ) => { if (event.pointerType !== "mouse" || !hasEvents) return; event.preventDefault(); @@ -942,19 +1072,27 @@ export const ReplayViewer = ({ event.currentTarget.setPointerCapture(event.pointerId); seekToDraggedStepListItem(event.target); }; - const handleStepListPointerMove = (event: React.PointerEvent) => { + const handleStepListPointerMove = ( + event: React.PointerEvent + ) => { if (!stepListPointerActiveRef.current) return; if (event.buttons === 0) { finishStepListPointerInteraction(event.currentTarget, event.pointerId); return; } - seekToDraggedStepListItem(document.elementFromPoint(event.clientX, event.clientY)); + seekToDraggedStepListItem( + document.elementFromPoint(event.clientX, event.clientY) + ); }; - const handleStepListPointerUp = (event: React.PointerEvent) => { + const handleStepListPointerUp = ( + event: React.PointerEvent + ) => { finishStepListPointerInteraction(event.currentTarget, event.pointerId); }; - const handleStepListPointerCancel = (event: React.PointerEvent) => { + const handleStepListPointerCancel = ( + event: React.PointerEvent + ) => { finishStepListPointerInteraction(event.currentTarget, event.pointerId); }; const handleStepListLostPointerCapture = () => { @@ -962,16 +1100,19 @@ export const ReplayViewer = ({ }; const playbackBarFillVisible = hasEvents && playbackBarValue > 0; const browserFrameSurfaceStyle = - browserFrameBackground !== undefined ? { background: browserFrameBackground } : undefined; + browserFrameBackground !== undefined + ? { background: browserFrameBackground } + : undefined; const playbackBarFillClassName = playbackBarValue >= playbackBarMax ? "rounded-full" : "rounded-l-full"; const playbackBarMarkerCount = Math.max( 0, - Math.floor((playbackBarMax - 1) / LIVE_PLAYBACK_BAR_MARKER_INTERVAL_MS), + Math.floor((playbackBarMax - 1) / LIVE_PLAYBACK_BAR_MARKER_INTERVAL_MS) ); const playbackBarMarkerPositions = Array.from( { length: playbackBarMarkerCount }, - (_, index) => `${(((index + 1) / (playbackBarMarkerCount + 1)) * 100).toFixed(2)}%`, + (_, index) => + `${(((index + 1) / (playbackBarMarkerCount + 1)) * 100).toFixed(2)}%` ); const visiblePlaybackBarMarkerPositions = playbackBarMarkerPositions.slice(1); const playbackStepMarkers = @@ -985,7 +1126,7 @@ export const ReplayViewer = ({ const markerPercent = Math.min( Math.max((markerTimeMs / playbackBarMax) * 100, 0), - 100, + 100 ).toFixed(2); return [ @@ -1003,7 +1144,8 @@ export const ReplayViewer = ({ const showFirstStepLabel = Boolean(steps && steps.steps.length > 0); const firstPlaybackStep = stepList[0]; const firstPlaybackStepTimeMs = firstPlaybackStep?.timeMs; - const firstPlaybackStepLabelDisabled = !hasEvents || firstPlaybackStepTimeMs === undefined; + const firstPlaybackStepLabelDisabled = + !hasEvents || firstPlaybackStepTimeMs === undefined; const playbackStepLabelClassName = "pointer-events-auto absolute top-full -mt-[17px] h-4.5 appearance-none bg-transparent p-0 [letter-spacing:0em] font-['SFProDisplay-Semibold','SF_Pro_Display',system-ui,sans-serif] text-[11.5px]/4.5 font-semibold text-[color(display-p3_0.553_0.553_0.553)] transition-opacity duration-150 ease-out hover:opacity-70 focus-visible:opacity-70 disabled:cursor-default disabled:opacity-50"; const playbackBar = ( @@ -1106,7 +1248,10 @@ export const ReplayViewer = ({ }} /> -
+
@@ -1138,7 +1283,9 @@ export const ReplayViewer = ({ )} {!live && ( - No events + + No events + )}
)} @@ -1146,7 +1293,9 @@ export const ReplayViewer = ({
0 ? "md:pl-[295px]" : "md:pl-6"}`} + className={`flex flex-col gap-2 rounded-[28px] px-3 pt-2 pb-3 md:gap-3 md:pr-6 md:pt-3 md:pb-5 ${ + stepList.length > 0 ? "md:pl-[295px]" : "md:pl-6" + }`} style={{ fontFamily: CONTROL_FONT_FAMILY }} >
@@ -1173,12 +1322,18 @@ export const ReplayViewer = ({
- + {timeLabel} {(!live || !isAtLiveEdge) && ( <> - / + + / + {totalTimeLabel} )} @@ -1190,7 +1345,9 @@ export const ReplayViewer = ({ className="inline-flex cursor-pointer items-center gap-1.5 rounded-full bg-red-500/10 px-2.5 py-1 transition-opacity hover:bg-red-500/20 active:scale-[0.97]" > Live @@ -1327,7 +1484,9 @@ export const ReplayViewer = ({ seekTo(firstPlaybackStepTimeMs); }} disabled={firstPlaybackStepLabelDisabled} - aria-label={`Jump to step 1: ${firstPlaybackStep?.title ?? "Step 1"}`} + aria-label={`Jump to step 1: ${ + firstPlaybackStep?.title ?? "Step 1" + }`} className={`${playbackStepLabelClassName} left-0`} > 1 diff --git a/apps/website/components/replay/test-selector.tsx b/apps/website/components/replay/test-selector.tsx new file mode 100644 index 000000000..7767da9c0 --- /dev/null +++ b/apps/website/components/replay/test-selector.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useAtom, useAtomValue } from "@effect/atom-react"; +import { Option } from "effect"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { PlanId, type TestPlan } from "@expect/shared/models"; +import { testListAtom } from "@/lib/replay/atoms/test-list"; +import { selectedTestIdAtom } from "@/lib/replay/atoms/selected-test"; + +const CONTROL_FONT_FAMILY = + '"SF Pro Display", "SFProDisplay-Medium", "Inter Variable", system-ui, sans-serif'; + +export const TestSelector = () => { + const testList = useAtomValue(testListAtom); + const [selectedId, setSelectedId] = useAtom(selectedTestIdAtom); + + return AsyncResult.builder(testList) + .onWaiting(() => ( +
+ Loading tests... +
+ )) + .onError((error) => { + const message = + error instanceof Error ? error.message : JSON.stringify(error, null, 2); + return ( +
+
+            Failed to load tests: {message}
+          
+
+ ); + }) + .onDefect((defect) => { + const message = + defect instanceof Error + ? defect.message + : JSON.stringify(defect, null, 2); + return ( +
+
+            Failed to load tests: {message}
+          
+
+ ); + }) + .onSuccess((tests) => { + if (tests.length === 0) { + return ( +
+ + No test runs yet + + + Run{" "} + + expect + {" "} + to create your first test. + +
+ ); + } + return ( +
+ +
+ ); + }) + .render(); +}; diff --git a/apps/website/lib/replay/atoms/live-updates.ts b/apps/website/lib/replay/atoms/live-updates.ts new file mode 100644 index 000000000..35a58f5a6 --- /dev/null +++ b/apps/website/lib/replay/atoms/live-updates.ts @@ -0,0 +1,17 @@ +import { Effect, Stream } from "effect"; +import { ViewerClient, ViewerRuntime } from "../rpc-client"; +import { __EXPECT_INJECTED_EVENTS__ } from "../injected-events"; +import { selectedTestIdAtom } from "./selected-test"; + +export const liveUpdatesAtom = ViewerRuntime.pull((ctx) => + Stream.unwrap( + Effect.gen(function* () { + const client = yield* ViewerClient; + if (__EXPECT_INJECTED_EVENTS__) { + return Stream.fromIterable(__EXPECT_INJECTED_EVENTS__); + } + const planId = yield* ctx.some(selectedTestIdAtom); + return client("artifact.StreamEvents", { planId }); + }), + ), +); diff --git a/apps/website/lib/replay/atoms/selected-test.ts b/apps/website/lib/replay/atoms/selected-test.ts new file mode 100644 index 000000000..da6e4ef3f --- /dev/null +++ b/apps/website/lib/replay/atoms/selected-test.ts @@ -0,0 +1,6 @@ +import * as Atom from "effect/unstable/reactivity/Atom"; +import { PlanId } from "@expect/shared/models"; + +export const selectedTestIdAtom = Atom.searchParam("testId", { + schema: PlanId, +}); diff --git a/apps/website/lib/replay/atoms/test-list.ts b/apps/website/lib/replay/atoms/test-list.ts new file mode 100644 index 000000000..873526c5a --- /dev/null +++ b/apps/website/lib/replay/atoms/test-list.ts @@ -0,0 +1,10 @@ +import { Effect } from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import { ViewerClient, ViewerRuntime } from "../rpc-client"; + +export const testListAtom = ViewerRuntime.atom( + Effect.gen(function* () { + const client = yield* ViewerClient; + return yield* client("artifact.ListTests", undefined); + }), +).pipe(Atom.keepAlive); diff --git a/apps/website/lib/replay/fetch-artifacts.ts b/apps/website/lib/replay/fetch-artifacts.ts new file mode 100644 index 000000000..d15db1dbe --- /dev/null +++ b/apps/website/lib/replay/fetch-artifacts.ts @@ -0,0 +1,19 @@ +import { Effect, Layer } from "effect"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { Socket } from "effect/unstable/socket"; +import { ArtifactRpcs } from "@expect/shared/rpcs"; +import { LIVE_VIEWER_RPC_PORT } from "@expect/shared"; +import type { Artifact } from "@expect/shared/models"; +import type { PlanId } from "@expect/shared/models"; + +const protocol = RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(`ws://localhost:${LIVE_VIEWER_RPC_PORT}/rpc`)), + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provide(RpcSerialization.layerNdjson), +); + +export const fetchAllArtifacts = (planId: PlanId): Promise => + Effect.gen(function* () { + const client = yield* RpcClient.make(ArtifactRpcs); + return yield* client["artifact.GetAllArtifacts"]({ planId }); + }).pipe(Effect.scoped, Effect.provide(protocol), Effect.runPromise); diff --git a/apps/website/lib/replay/injected-events.ts b/apps/website/lib/replay/injected-events.ts new file mode 100644 index 000000000..755417cec --- /dev/null +++ b/apps/website/lib/replay/injected-events.ts @@ -0,0 +1,16 @@ +import { Schema } from "effect"; +import { Artifact } from "@expect/shared/models"; + +const ArtifactJson = Schema.toCodecJson(Artifact); +const decodeArtifacts = Schema.decodeUnknownSync(Schema.Array(ArtifactJson)); + +declare global { + interface Window { + __EXPECT_INJECTED_EVENTS__?: unknown; + } +} + +export const __EXPECT_INJECTED_EVENTS__: readonly Artifact[] | undefined = + typeof window !== "undefined" && window.__EXPECT_INJECTED_EVENTS__ + ? decodeArtifacts(window.__EXPECT_INJECTED_EVENTS__) + : undefined; diff --git a/apps/website/lib/replay/rpc-client.ts b/apps/website/lib/replay/rpc-client.ts new file mode 100644 index 000000000..9edf8c0d2 --- /dev/null +++ b/apps/website/lib/replay/rpc-client.ts @@ -0,0 +1,25 @@ +import { Layer, Logger, References } from "effect"; +import { AtomRpc } from "effect/unstable/reactivity"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { Socket } from "effect/unstable/socket"; +import { ArtifactRpcs } from "@expect/shared/rpcs"; +import { LIVE_VIEWER_RPC_PORT } from "@expect/shared"; + +const protocol = RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(`ws://localhost:${LIVE_VIEWER_RPC_PORT}/rpc`)), + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provide(RpcSerialization.layerNdjson), +); + +export class ViewerClient extends AtomRpc.Service()("ViewerClient", { + group: ArtifactRpcs, + protocol, +}) {} + +const ViewerLive = ViewerClient.layer.pipe( + Layer.provideMerge(Layer.succeed(References.MinimumLogLevel, "Error")), + Layer.provideMerge(Logger.layer([Logger.consolePretty()])), +); + +export const ViewerRuntime = Atom.runtime(ViewerLive); diff --git a/apps/website/next-env.d.ts b/apps/website/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/website/next-env.d.ts +++ b/apps/website/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/website/next.config.ts b/apps/website/next.config.ts index cb651cdc0..1308a9f9e 100644 --- a/apps/website/next.config.ts +++ b/apps/website/next.config.ts @@ -1,5 +1,8 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + trailingSlash: true, + allowedDevOrigins: ["bens-macbook-pro.tail36228.ts.net"], +}; export default nextConfig; diff --git a/apps/website/package.json b/apps/website/package.json index 5009812c5..87aa98046 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -12,8 +12,11 @@ }, "dependencies": { "@base-ui/react": "^1.3.0", + "@effect/atom-react": "4.0.0-beta.35", + "@expect/shared": "workspace:*", "@posthog/rrweb": "^0.0.50", "@tanstack/react-query": "^5.95.2", + "effect": "4.0.0-beta.35", "@vercel/analytics": "^2.0.1", "calligraph": "^1.4.1", "class-variance-authority": "^0.7.1", diff --git a/apps/website/pnpm-workspace.yaml b/apps/website/pnpm-workspace.yaml deleted file mode 100644 index 581a9d5b5..000000000 --- a/apps/website/pnpm-workspace.yaml +++ /dev/null @@ -1,3 +0,0 @@ -ignoredBuiltDependencies: - - sharp - - unrs-resolver diff --git a/apps/website/scripts/record-demo.ts b/apps/website/scripts/record-demo.ts index c5428bbfa..a6b8b8da6 100644 --- a/apps/website/scripts/record-demo.ts +++ b/apps/website/scripts/record-demo.ts @@ -1,12 +1,12 @@ import { chromium, type Locator, type Page } from "playwright"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { DEMO_STEP_DEFINITIONS, DEMO_TARGET_URL } from "../lib/demo/constants"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const RUNTIME_SCRIPT_PATH = join( +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const RUNTIME_SCRIPT_PATH = path.join( __dirname, "..", "..", @@ -17,7 +17,7 @@ const RUNTIME_SCRIPT_PATH = join( "generated", "runtime-script.ts", ); -const RUNTIME_MODULE = readFileSync(RUNTIME_SCRIPT_PATH, "utf-8"); +const RUNTIME_MODULE = fs.readFileSync(RUNTIME_SCRIPT_PATH, "utf-8"); const inlineExportPrefix = "export const RUNTIME_SCRIPT = "; const multilineExportPrefix = "export const RUNTIME_SCRIPT =\n "; const runtimeScriptPrefix = RUNTIME_MODULE.startsWith(inlineExportPrefix) @@ -35,7 +35,7 @@ const RUNTIME_SCRIPT: string = new Function( )(); const MANUAL_FLAG = "--manual"; -const OUTPUT_PATH = join(__dirname, "..", "lib", "recorded-demo-events.json"); +const OUTPUT_PATH = path.join(__dirname, "..", "lib", "recorded-demo-events.json"); const POLL_INTERVAL_MS = 500; const DEFAULT_VIEWPORT_WIDTH_PX = 1280; const DEFAULT_VIEWPORT_HEIGHT_PX = 720; @@ -226,8 +226,8 @@ const run = async () => { return; } - mkdirSync(dirname(OUTPUT_PATH), { recursive: true }); - writeFileSync(OUTPUT_PATH, JSON.stringify(allEvents, undefined, 2)); + fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(allEvents, undefined, 2)); console.log(`Saved to: ${OUTPUT_PATH}`); }; diff --git a/package.json b/package.json index 00e4591c1..3e3369235 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,12 @@ "@changesets/cli": "^2.27.0", "@typescript/native-preview": "7.0.0-dev.20260319.1", "@voidzero-dev/vite-plus-core": "^0.1.12", + "effect": "4.0.0-beta.35", + "@effect/platform-node": "4.0.0-beta.35", "turbo": "^2.8.17", "typescript": "^5.7.0", - "vite-plus": "^0.1.12" + "vite-plus": "^0.1.12", + "@expect/shared": "workspace:*" }, "packageManager": "pnpm@10.29.1" } diff --git a/packages/agent/src/acp-client.ts b/packages/agent/src/acp-client.ts index 6d28e3753..1bc581ff0 100644 --- a/packages/agent/src/acp-client.ts +++ b/packages/agent/src/acp-client.ts @@ -1,7 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; +import * as fs from "node:fs"; import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as path from "node:path"; import * as acp from "@agentclientprotocol/sdk"; import { Cause, @@ -29,6 +28,7 @@ import { } from "@expect/shared/models"; import { hasStringMessage } from "@expect/shared/utils"; import { detectLaunchedFrom } from "@expect/shared/launched-from"; +import { CurrentPlanId } from "@expect/shared/models"; import { buildSessionMeta } from "./build-session-meta"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -175,13 +175,13 @@ const makeRequire = () => const resolvePackageDir = (require: NodeRequire, packageName: string): string => { try { - return dirname(require.resolve(`${packageName}/package.json`)); + return path.dirname(require.resolve(`${packageName}/package.json`)); } catch { const paths = require.resolve.paths(packageName) ?? []; for (const searchPath of paths) { - const candidate = join(searchPath, packageName); + const candidate = path.join(searchPath, packageName); try { - const content = JSON.parse(readFileSync(join(candidate, "package.json"), "utf-8")); + const content = JSON.parse(fs.readFileSync(path.join(candidate, "package.json"), "utf-8")); if (content.name === packageName) return candidate; } catch {} } @@ -192,17 +192,17 @@ const resolvePackageDir = (require: NodeRequire, packageName: string): string => const resolvePackageBin = (packageName: string): string => { const require = makeRequire(); const packageDir = resolvePackageDir(require, packageName); - const packageJson = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf-8")); + const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf-8")); if (typeof packageJson.bin === "string") { - return join(packageDir, packageJson.bin); + return path.join(packageDir, packageJson.bin); } if (typeof packageJson.bin === "object" && packageJson.bin !== null) { const firstBinPath = String(Object.values(packageJson.bin)[0]); - return join(packageDir, firstBinPath); + return path.join(packageDir, firstBinPath); } if (packageJson.main) { - return join(packageDir, packageJson.main); + return path.join(packageDir, packageJson.main); } throw new Error(`Cannot resolve bin entry for ${packageName}`); }; @@ -288,10 +288,14 @@ export class AcpAdapter extends ServiceMap.Service< Effect.flatMap((token) => token.trim().length > 0 ? Effect.void - : new AcpProviderUnauthenticatedError({ provider: "copilot" }).asEffect(), + : new AcpProviderUnauthenticatedError({ + provider: "copilot", + }).asEffect(), ), Effect.catchTag("PlatformError", () => - new AcpProviderUnauthenticatedError({ provider: "copilot" }).asEffect(), + new AcpProviderUnauthenticatedError({ + provider: "copilot", + }).asEffect(), ), ); @@ -331,7 +335,9 @@ export class AcpAdapter extends ServiceMap.Service< Effect.flatMap(({ active }) => active.length > 0 ? Effect.void - : new AcpProviderUnauthenticatedError({ provider: "gemini" }).asEffect(), + : new AcpProviderUnauthenticatedError({ + provider: "gemini", + }).asEffect(), ), Effect.catchReason("PlatformError", "NotFound", () => new AcpProviderUnauthenticatedError({ provider: "gemini" }).asEffect(), @@ -386,11 +392,16 @@ export class AcpAdapter extends ServiceMap.Service< Effect.flatMap((output) => output.trim().length > 0 ? Effect.void - : new AcpProviderUnauthenticatedError({ provider: "cursor" }).asEffect(), + : new AcpProviderUnauthenticatedError({ + provider: "cursor", + }).asEffect(), ), Effect.timeoutOrElse({ duration: ACP_AUTH_CHECK_TIMEOUT, - onTimeout: () => new AcpProviderUnauthenticatedError({ provider: "cursor" }).asEffect(), + onTimeout: () => + new AcpProviderUnauthenticatedError({ + provider: "cursor", + }).asEffect(), }), Effect.catchTag("PlatformError", () => new AcpProviderUnauthenticatedError({ provider: "cursor" }).asEffect(), @@ -426,7 +437,9 @@ export class AcpAdapter extends ServiceMap.Service< const apiKeyOption = yield* Config.option(Config.string("FACTORY_API_KEY")); if (!Option.isSome(apiKeyOption) || apiKeyOption.value.trim().length === 0) { - return yield* new AcpProviderUnauthenticatedError({ provider: "droid" }); + return yield* new AcpProviderUnauthenticatedError({ + provider: "droid", + }); } return AcpAdapter.of({ @@ -446,7 +459,10 @@ export class AcpAdapter extends ServiceMap.Service< spawner.string, Effect.timeoutOrElse({ duration: ACP_AUTH_CHECK_TIMEOUT, - onTimeout: () => new AcpProviderNotInstalledError({ provider: "opencode" }).asEffect(), + onTimeout: () => + new AcpProviderNotInstalledError({ + provider: "opencode", + }).asEffect(), }), Effect.catchReason("PlatformError", "NotFound", () => new AcpProviderNotInstalledError({ provider: "opencode" }).asEffect(), @@ -461,14 +477,21 @@ export class AcpAdapter extends ServiceMap.Service< Effect.flatMap((output) => output.trim().length > 0 ? Effect.void - : new AcpProviderUnauthenticatedError({ provider: "opencode" }).asEffect(), + : new AcpProviderUnauthenticatedError({ + provider: "opencode", + }).asEffect(), ), Effect.timeoutOrElse({ duration: ACP_AUTH_CHECK_TIMEOUT, - onTimeout: () => new AcpProviderUnauthenticatedError({ provider: "opencode" }).asEffect(), + onTimeout: () => + new AcpProviderUnauthenticatedError({ + provider: "opencode", + }).asEffect(), }), Effect.catchTag("PlatformError", () => - new AcpProviderUnauthenticatedError({ provider: "opencode" }).asEffect(), + new AcpProviderUnauthenticatedError({ + provider: "opencode", + }).asEffect(), ), ); @@ -485,6 +508,7 @@ export class AcpAdapter extends ServiceMap.Service< export class AcpClient extends ServiceMap.Service()("@expect/AcpClient", { make: Effect.gen(function* () { const adapter = yield* AcpAdapter; + const planId = yield* CurrentPlanId; yield* Effect.annotateLogsScoped({ adapter: adapter.args[0] }); yield* Effect.logInfo(`Initializing AcpClient`); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -602,11 +626,7 @@ export class AcpClient extends ServiceMap.Service()("@expect/AcpClien const connection = new acp.ClientSideConnection((_agent) => client, ndJsonStream); - const browserMcpBinPath = (() => { - const colocated = fileURLToPath(new URL("./browser-mcp.js", import.meta.url)); - if (existsSync(colocated)) return colocated; - return fileURLToPath(new URL("../../../apps/cli/dist/browser-mcp.js", import.meta.url)); - })(); + const browserMcpBinPath = makeRequire().resolve("expect-cli/browser-mcp"); const buildMcpServers = ( env: ReadonlyArray<{ name: string; value: string }>, @@ -614,7 +634,7 @@ export class AcpClient extends ServiceMap.Service()("@expect/AcpClien { command: process.execPath, args: [browserMcpBinPath], - env: [...env], + env: [{ name: "EXPECT_PLAN_ID", value: planId as string }, ...env], name: "browser", }, ]; @@ -696,6 +716,7 @@ export class AcpClient extends ServiceMap.Service()("@expect/AcpClien } yield* Effect.logInfo("ACP session created", { sessionId }); + yield* Effect.logInfo(`Resume this session: claude --resume ${sessionId}`); if (response.configOptions && response.configOptions.length > 0) { const decoded = yield* decodeConfigOptions(response.configOptions); @@ -800,11 +821,15 @@ export class AcpClient extends ServiceMap.Service()("@expect/AcpClien while: (stalled) => !stalled, }); if (isStalled) { - yield* Effect.logWarning("ACP stream inactivity timeout", { sessionId }); + yield* Effect.logWarning("ACP stream inactivity timeout", { + sessionId, + }); yield* Queue.fail( updatesQueue, new AcpStreamError({ - cause: `Agent produced no output for ${ACP_STREAM_INACTIVITY_TIMEOUT_MS / 1000}s — the agent may be stalled`, + cause: `Agent produced no output for ${ + ACP_STREAM_INACTIVITY_TIMEOUT_MS / 1000 + }s — the agent may be stalled`, }), ); } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 2a969b0bd..2f34b8b8e 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -11,7 +11,7 @@ import { type AcpStreamError, type SessionId, } from "./acp-client"; -import { AcpSessionUpdate, type AcpConfigOption } from "@expect/shared/models"; +import { AcpSessionUpdate, type AcpConfigOption, CurrentPlanId } from "@expect/shared/models"; import { AgentStreamOptions } from "./types"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { PlatformError } from "effect/PlatformError"; @@ -95,8 +95,8 @@ export class Agent extends ServiceMap.Service< static layerOpencode = Agent.layerAcp.pipe(Layer.provide(AcpAdapter.layerOpencode)); static layerDroid = Agent.layerAcp.pipe(Layer.provide(AcpAdapter.layerDroid)); - static layerFor = (backend: AgentBackend): Layer.Layer => { - const layers: Record> = { + static layerFor = (backend: AgentBackend): Layer.Layer => { + const layers: Record> = { claude: Agent.layerClaude, codex: Agent.layerCodex, copilot: Agent.layerCopilot, diff --git a/packages/agent/src/build-session-meta.ts b/packages/agent/src/build-session-meta.ts index ab847c286..73f968261 100644 --- a/packages/agent/src/build-session-meta.ts +++ b/packages/agent/src/build-session-meta.ts @@ -1,5 +1,8 @@ +import type { NewSessionMeta } from "@agentclientprotocol/claude-agent-acp"; import type { AgentProvider } from "@expect/shared/models"; +const BROWSER_MCP_SERVER_NAME = "browser"; + interface BuildSessionMetaOptions { readonly provider: AgentProvider; readonly systemPrompt?: string; @@ -9,14 +12,20 @@ interface BuildSessionMetaOptions { } export const buildSessionMeta = ({ provider, systemPrompt, metadata }: BuildSessionMetaOptions) => { - const meta = { + const meta: NewSessionMeta = { ...(systemPrompt ? { systemPrompt } : {}), - ...(metadata?.isGitHubActions && provider === "claude" + ...(provider === "claude" ? { claudeCode: { options: { - effort: "high" as const, - thinking: { type: "adaptive" as const }, + tools: { type: "preset", preset: "claude_code" }, + settings: { + allowedMcpServers: [{ serverName: BROWSER_MCP_SERVER_NAME }], + }, + ...(metadata?.isGitHubActions && { + effort: "high" as const, + thinking: { type: "adaptive" as const }, + }), }, }, } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 749ad4891..ad90b3035 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -2,7 +2,6 @@ export { AgentStreamOptions } from "./types"; export * from "./acp-client"; export { Agent, type AgentBackend } from "./agent"; -export { PROVIDER_ID, EMPTY_USAGE, STOP_REASON } from "./schemas/index"; export { detectAvailableAgents, toDisplayName, diff --git a/packages/agent/src/schemas/ai-sdk.ts b/packages/agent/src/schemas/ai-sdk.ts deleted file mode 100644 index 5333b5e02..000000000 --- a/packages/agent/src/schemas/ai-sdk.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const PROVIDER_ID = "expect-agent"; - -export const EMPTY_USAGE = { - inputTokens: { - total: undefined, - noCache: undefined, - cacheRead: undefined, - cacheWrite: undefined, - }, - outputTokens: { total: undefined, text: undefined, reasoning: undefined }, -}; - -export const STOP_REASON = { unified: "stop" as const, raw: undefined }; diff --git a/packages/agent/src/schemas/index.ts b/packages/agent/src/schemas/index.ts deleted file mode 100644 index ff015dbfc..000000000 --- a/packages/agent/src/schemas/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PROVIDER_ID, EMPTY_USAGE, STOP_REASON } from "./ai-sdk"; diff --git a/packages/agent/tests/agent.test.ts b/packages/agent/tests/agent.test.ts index 4522f1b49..a67b0a995 100644 --- a/packages/agent/tests/agent.test.ts +++ b/packages/agent/tests/agent.test.ts @@ -1,15 +1,18 @@ import { describe, expect, it } from "vite-plus/test"; -import { Effect, Option, Stream } from "effect"; +import { Effect, Layer, Option, Stream } from "effect"; import { Agent } from "../src/agent"; import { AgentStreamOptions } from "../src/types"; import { isCommandAvailable } from "@expect/shared/is-command-available"; +import { CurrentPlanId, PlanId } from "@expect/shared/models"; const hasCodex = isCommandAvailable("codex"); const hasClaude = isCommandAvailable("claude"); +const testPlanId = Layer.succeed(CurrentPlanId, PlanId.makeUnsafe(crypto.randomUUID())); + const TEST_LAYERS = [ - ["codex-acp", Agent.layerCodex, hasCodex], - ["claude-acp", Agent.layerClaude, hasClaude], + ["codex-acp", Agent.layerCodex.pipe(Layer.provide(testPlanId)), hasCodex], + ["claude-acp", Agent.layerClaude.pipe(Layer.provide(testPlanId)), hasClaude], ] as const; const makeOptions = (prompt: string): AgentStreamOptions => diff --git a/packages/agent/tests/build-session-meta.test.ts b/packages/agent/tests/build-session-meta.test.ts index 5c1a3d6f4..b1906925a 100644 --- a/packages/agent/tests/build-session-meta.test.ts +++ b/packages/agent/tests/build-session-meta.test.ts @@ -21,6 +21,24 @@ describe("buildSessionMeta", () => { expect(sessionMeta).toEqual({ systemPrompt: "Test prompt" }); }); + it("restricts Claude Code to built-in tools and the browser MCP server", () => { + const sessionMeta = buildSessionMeta({ + provider: "claude", + metadata: { isGitHubActions: false }, + }); + + expect(sessionMeta).toEqual({ + claudeCode: { + options: { + tools: { type: "preset", preset: "claude_code" }, + settings: { + allowedMcpServers: [{ serverName: "browser" }], + }, + }, + }, + }); + }); + it("keeps the system prompt for claude outside GitHub Actions", () => { const sessionMeta = buildSessionMeta({ provider: "claude", @@ -28,7 +46,17 @@ describe("buildSessionMeta", () => { metadata: { isGitHubActions: false }, }); - expect(sessionMeta).toEqual({ systemPrompt: "Test prompt" }); + expect(sessionMeta).toEqual({ + systemPrompt: "Test prompt", + claudeCode: { + options: { + tools: { type: "preset", preset: "claude_code" }, + settings: { + allowedMcpServers: [{ serverName: "browser" }], + }, + }, + }, + }); }); it("sets Claude effort to high in GitHub Actions", () => { @@ -40,6 +68,10 @@ describe("buildSessionMeta", () => { expect(sessionMeta).toEqual({ claudeCode: { options: { + tools: { type: "preset", preset: "claude_code" }, + settings: { + allowedMcpServers: [{ serverName: "browser" }], + }, effort: "high", thinking: { type: "adaptive" }, }, @@ -58,6 +90,10 @@ describe("buildSessionMeta", () => { systemPrompt: "Test prompt", claudeCode: { options: { + tools: { type: "preset", preset: "claude_code" }, + settings: { + allowedMcpServers: [{ serverName: "browser" }], + }, effort: "high", thinking: { type: "adaptive" }, }, diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json index 57bf531c0..74ca7f8f2 100644 --- a/packages/agent/tsconfig.json +++ b/packages/agent/tsconfig.json @@ -2,8 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src", "types": ["node"] }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/packages/browser/package.json b/packages/browser/package.json index 544881e43..eedb58ef8 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -33,6 +33,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.35", "@types/node": "^22.15.0", "esbuild": "^0.25.0", "typescript": "^5.7.0" diff --git a/packages/browser/src/accessibility.ts b/packages/browser/src/accessibility.ts index 9e5581712..fa0ad8102 100644 --- a/packages/browser/src/accessibility.ts +++ b/packages/browser/src/accessibility.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import * as fs from "node:fs"; import { createRequire } from "node:module"; import type { Page } from "playwright"; import { Effect, Schema } from "effect"; @@ -66,7 +66,7 @@ const loadAceScript = () => { if (!cachedAceScript) { const require = createRequire(import.meta.url); const aceScriptPath = require.resolve("accessibility-checker-engine/ace.js"); - cachedAceScript = readFileSync(aceScriptPath, "utf8"); + cachedAceScript = fs.readFileSync(aceScriptPath, "utf8"); } return cachedAceScript; }; diff --git a/packages/browser/src/artifact-storage.ts b/packages/browser/src/artifact-storage.ts new file mode 100644 index 000000000..5ecb0ff66 --- /dev/null +++ b/packages/browser/src/artifact-storage.ts @@ -0,0 +1,46 @@ +import { Config, Effect, Layer, ServiceMap } from "effect"; +import { NodeSocket } from "@effect/platform-node"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import { RpcSerialization } from "effect/unstable/rpc"; +import { PlanId, type Artifact } from "@expect/shared/models"; +import { ArtifactRpcs } from "@expect/shared/rpcs"; +import { LIVE_VIEWER_RPC_URL } from "@expect/shared"; + +export class ArtifactStorage extends ServiceMap.Service< + ArtifactStorage, + { + readonly push: (artifacts: readonly Artifact[]) => Effect.Effect; + } +>()("@browser/ArtifactStorage") { + static layerNoop = Layer.succeed(this, { + push: () => Effect.void, + }); + + static layerRpc = Layer.effect(this)( + Effect.gen(function* () { + const rpcClient = yield* RpcClient.make(ArtifactRpcs); + const planIdString = yield* Config.string("EXPECT_PLAN_ID"); + const planId = PlanId.makeUnsafe(planIdString); + + return { + push: (artifacts) => + rpcClient["artifact.PushArtifacts"]({ + planId, + batch: [...artifacts], + }).pipe( + Effect.ignore({ + message: "Failed to push artifacts to live viewer", + log: "Warn", + }), + ), + }; + }), + ).pipe( + Layer.provide( + RpcClient.layerProtocolSocket().pipe( + Layer.provide(NodeSocket.layerWebSocket(LIVE_VIEWER_RPC_URL)), + Layer.provide(RpcSerialization.layerNdjson), + ), + ), + ); +} diff --git a/packages/browser/src/artifacts.ts b/packages/browser/src/artifacts.ts new file mode 100644 index 000000000..8f27874b6 --- /dev/null +++ b/packages/browser/src/artifacts.ts @@ -0,0 +1,36 @@ +import { Effect, Layer, ServiceMap } from "effect"; +import type { Artifact } from "@expect/shared/models"; +import { ArtifactStorage } from "./artifact-storage"; + +export class Artifacts extends ServiceMap.Service()("@browser/Artifacts", { + make: Effect.gen(function* () { + const storage = yield* ArtifactStorage; + const items: Artifact[] = []; + + const push = Effect.fn("Artifacts.push")(function* (artifacts: readonly Artifact[]) { + for (const artifact of artifacts) { + items.push(artifact); + } + yield* storage.push(artifacts); + }); + + const all = () => items as readonly Artifact[]; + + const clear = () => { + items.length = 0; + }; + + return { push, all, clear } as const; + }), +}) { + static layer = Layer.effect(this)(this.make).pipe(Layer.provide(ArtifactStorage.layerRpc)); + + static layerTest = (onPush: (artifacts: readonly Artifact[]) => void) => + Layer.effect(this)(this.make).pipe( + Layer.provide( + Layer.succeed(ArtifactStorage, { + push: (artifacts) => Effect.sync(() => onPush(artifacts)), + }), + ), + ); +} diff --git a/packages/browser/src/browser.ts b/packages/browser/src/browser.ts deleted file mode 100644 index ecc74dec0..000000000 --- a/packages/browser/src/browser.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { Browsers, Cookies, layerLive, browserKeyOf, Cookie } from "@expect/cookies"; -import type { Browser as BrowserProfile } from "@expect/cookies"; -import { chromium, webkit, firefox } from "playwright"; -import type { Locator, Page } from "playwright"; -import type { BrowserEngine } from "./types"; -import { Array as Arr, Effect, Layer, Option, ServiceMap } from "effect"; - -const cookiesLayer = Layer.mergeAll(layerLive, Cookies.layer); -import { - CONTENT_ROLES, - HEADLESS_CHROMIUM_ARGS, - INTERACTIVE_ROLES, - NAVIGATION_DETECT_DELAY_MS, - OVERLAY_CONTAINER_ID, - POST_NAVIGATION_SETTLE_MS, - REPLAY_PLAYER_HEIGHT_PX, - REPLAY_PLAYER_WIDTH_PX, - REF_PREFIX, - SNAPSHOT_TIMEOUT_MS, -} from "./constants"; -import { - BrowserLaunchError, - CdpConnectionError, - NavigationError, - SnapshotTimeoutError, -} from "./errors"; -import { toActionError } from "./utils/action-error"; -import { compactTree } from "./utils/compact-tree"; -import { createLocator } from "./utils/create-locator"; -import { evaluateRuntime } from "./utils/evaluate-runtime"; -import { findCursorInteractive } from "./utils/find-cursor-interactive"; -import { getIndentLevel } from "./utils/get-indent-level"; -import { parseAriaLine } from "./utils/parse-aria-line"; -import { resolveNthDuplicates } from "./utils/resolve-nth-duplicates"; -import { computeSnapshotStats } from "./utils/snapshot-stats"; -import { RUNTIME_SCRIPT } from "./generated/runtime-script"; -import type { - AnnotatedScreenshotOptions, - Annotation, - CreatePageOptions, - RefMap, - SnapshotOptions, -} from "./types"; -import type { ScrollContainerResult } from "./runtime/scroll-detection"; - -const BROWSER_ENGINES = { chromium, webkit, firefox } as const; - -const resolveBrowserType = (engine: BrowserEngine) => BROWSER_ENGINES[engine]; - -const shouldAssignRef = (role: string, name: string, interactive?: boolean): boolean => { - if (INTERACTIVE_ROLES.has(role)) return true; - if (interactive) return false; - return CONTENT_ROLES.has(role) && name.length > 0; -}; - -const toBrowserLaunchError = (cause: unknown) => - new BrowserLaunchError({ - cause: cause instanceof Error ? cause.message : String(cause), - }); - -const resolveDefaultBrowserContext = Effect.fn("Browser.resolveDefaultBrowserContext")( - function* () { - const browsers = yield* Browsers; - const maybeDefault = yield* browsers - .defaultBrowser() - .pipe( - Effect.catchTag("ListBrowsersError", () => Effect.succeed(Option.none())), - ); - - return { preferredProfile: Option.getOrUndefined(maybeDefault) }; - }, - Effect.provide(layerLive), -); - -const extractCookiesForProfile = Effect.fn("Browser.extractCookiesForProfile")( - function* (cookiesService: typeof Cookies.Service, profile: BrowserProfile) { - return yield* cookiesService.extract(profile); - }, - Effect.catchTags({ - ExtractionError: () => Effect.succeed([]), - PlatformError: Effect.die, - }), -); - -const dedupCookies = (cookies: Cookie[]) => - Arr.dedupeWith( - cookies, - (cookieA, cookieB) => - cookieA.name === cookieB.name && - cookieA.domain === cookieB.domain && - cookieA.path === cookieB.path, - ); - -const isSiblingProfile = (profile: BrowserProfile, reference: BrowserProfile) => { - if (profile._tag !== reference._tag) return false; - if (profile._tag === "ChromiumBrowser" && reference._tag === "ChromiumBrowser") { - return profile.key === reference.key && profile.profilePath !== reference.profilePath; - } - if (profile._tag === "FirefoxBrowser" && reference._tag === "FirefoxBrowser") { - return profile.profilePath !== reference.profilePath; - } - return false; -}; - -const extractDefaultBrowserCookies = Effect.fn("Browser.extractDefaultBrowserCookies")(function* ( - url: string, - preferredProfile: BrowserProfile | undefined, -) { - if (!preferredProfile) return []; - - const cookiesService = yield* Cookies; - const browsers = yield* Browsers; - - const allProfiles = yield* browsers.list.pipe( - Effect.catchTag("ListBrowsersError", () => Effect.succeed([])), - ); - - const results = yield* Effect.forEach( - [ - preferredProfile, - ...allProfiles.filter((profile) => isSiblingProfile(profile, preferredProfile)), - ], - (profile) => extractCookiesForProfile(cookiesService, profile), - { concurrency: "unbounded" }, - ); - - // Preferred profile is first, so its cookies win when multiple profiles share the same cookie identity. - return dedupCookies(results.flat()); -}, Effect.provide(cookiesLayer)); - -const extractCookiesForBrowserKeys = Effect.fn("Browser.extractCookiesForBrowserKeys")(function* ( - browserKeys: readonly string[], -) { - const cookiesService = yield* Cookies; - const browsers = yield* Browsers; - const allProfiles = yield* browsers.list.pipe( - Effect.catchTag("ListBrowsersError", () => Effect.succeed([])), - ); - - const matchingProfiles = allProfiles.filter((profile) => - browserKeys.includes(browserKeyOf(profile)), - ); - - const results = yield* Effect.forEach( - matchingProfiles, - (profile) => extractCookiesForProfile(cookiesService, profile), - { concurrency: "unbounded" }, - ); - - return dedupCookies(results.flat()); -}, Effect.provide(cookiesLayer)); - -const appendCursorInteractiveElements = Effect.fn("Browser.appendCursorInteractive")(function* ( - page: Page, - filteredLines: string[], - refs: RefMap, - refCount: number, - options: SnapshotOptions, -) { - const cursorElements = yield* findCursorInteractive(page, options.selector); - if (cursorElements.length === 0) return refCount; - - const existingNames = new Set(Object.values(refs).map((entry) => entry.name.toLowerCase())); - const newLines: string[] = []; - - for (const element of cursorElements) { - if (existingNames.has(element.text.toLowerCase())) continue; - existingNames.add(element.text.toLowerCase()); - - const ref = `${REF_PREFIX}${++refCount}`; - refs[ref] = { - role: "clickable", - name: element.text, - selector: element.selector, - }; - newLines.push(`- clickable "${element.text}" [ref=${ref}] [${element.reason}]`); - } - - if (newLines.length > 0) { - filteredLines.push("# Cursor-interactive elements:"); - filteredLines.push(...newLines); - } - - return refCount; -}); - -const injectOverlayLabels = (page: Page, labels: Array<{ label: number; x: number; y: number }>) => - evaluateRuntime(page, "injectOverlayLabels", OVERLAY_CONTAINER_ID, labels); - -export class Browser extends ServiceMap.Service()("@browser/Browser", { - make: Effect.gen(function* () { - const createPage = Effect.fn("Browser.createPage")(function* ( - url: string | undefined, - options: CreatePageOptions = {}, - ) { - const engine = options.browserType ?? "chromium"; - yield* Effect.annotateCurrentSpan({ - url: url ?? "about:blank", - cdp: Boolean(options.cdpUrl), - browserType: engine, - }); - - const cdpEndpoint = engine === "chromium" ? options.cdpUrl : undefined; - const browserType = resolveBrowserType(engine); - const browser = cdpEndpoint - ? yield* Effect.tryPromise({ - try: () => chromium.connectOverCDP(cdpEndpoint), - catch: (cause) => - new CdpConnectionError({ - endpointUrl: cdpEndpoint, - cause: cause instanceof Error ? cause.message : String(cause), - }), - }) - : yield* Effect.tryPromise({ - try: () => - browserType.launch({ - headless: !options.headed, - executablePath: options.executablePath, - args: engine === "chromium" && !options.headed ? HEADLESS_CHROMIUM_ARGS : [], - }), - catch: toBrowserLaunchError, - }); - - const setupPage = Effect.gen(function* () { - const defaultBrowserContext = - options.cookies === true - ? yield* resolveDefaultBrowserContext() - : { preferredProfile: undefined }; - - const profileLocale = - defaultBrowserContext.preferredProfile?._tag === "ChromiumBrowser" - ? defaultBrowserContext.preferredProfile.locale - : undefined; - - const contextOptions: Parameters[0] = {}; - if (profileLocale) { - contextOptions.locale = profileLocale; - } - if (options.videoOutputDir) { - contextOptions.recordVideo = { - dir: options.videoOutputDir, - size: { width: REPLAY_PLAYER_WIDTH_PX, height: REPLAY_PLAYER_HEIGHT_PX }, - }; - } - - const isCdpConnected = Boolean(cdpEndpoint); - const existingContexts = isCdpConnected ? browser.contexts() : []; - const context = - existingContexts.length > 0 - ? existingContexts[0]! - : yield* Effect.tryPromise({ - try: () => browser.newContext(contextOptions), - catch: toBrowserLaunchError, - }); - - yield* Effect.tryPromise({ - try: () => context.addInitScript(RUNTIME_SCRIPT), - catch: toBrowserLaunchError, - }); - - if (isCdpConnected && existingContexts.length > 0) { - const existingPages = context.pages(); - for (const existingPage of existingPages) { - yield* Effect.tryPromise({ - try: () => existingPage.evaluate(RUNTIME_SCRIPT), - catch: toBrowserLaunchError, - }).pipe(Effect.catchTag("BrowserLaunchError", () => Effect.void)); - } - } - - if (options.cookies && !isCdpConnected) { - const cookies = Array.isArray(options.cookies) - ? options.cookies - : yield* extractDefaultBrowserCookies( - url ?? "", - defaultBrowserContext.preferredProfile, - ); - yield* Effect.tryPromise({ - try: () => context.addCookies(cookies.map((cookie) => cookie.playwrightFormat)), - catch: toBrowserLaunchError, - }); - } - - const existingPages = context.pages(); - const page = - isCdpConnected && existingPages.length > 0 - ? existingPages[0]! - : yield* Effect.tryPromise({ - try: () => context.newPage(), - catch: toBrowserLaunchError, - }); - - if (url) { - yield* Effect.tryPromise({ - try: () => page.goto(url, { waitUntil: options.waitUntil ?? "load" }), - catch: (cause) => - new NavigationError({ - url, - cause: cause instanceof Error ? cause.message : String(cause), - }), - }); - } - - return { browser, context, page }; - }); - - return yield* setupPage.pipe( - Effect.tapError(() => { - if (cdpEndpoint) return Effect.void; - return Effect.tryPromise(() => browser.close()).pipe( - Effect.catchTag("UnknownError", () => Effect.void), - ); - }), - ); - }); - - const NO_SCROLL_CONTAINERS: ScrollContainerResult[] = []; - - const takeAriaSnapshot = Effect.fn("Browser.takeAriaSnapshot")(function* ( - page: Page, - options: SnapshotOptions, - ) { - const timeout = options.timeout ?? SNAPSHOT_TIMEOUT_MS; - const selector = options.selector ?? "body"; - const useViewportAware = options.viewportAware ?? true; - - const scrollContainers: ScrollContainerResult[] = useViewportAware - ? yield* evaluateRuntime(page, "prepareViewportSnapshot").pipe( - Effect.catchCause((cause) => - Effect.logDebug("Viewport snapshot preparation failed, falling back to full tree", { - cause, - }).pipe(Effect.as(NO_SCROLL_CONTAINERS)), - ), - ) - : NO_SCROLL_CONTAINERS; - - const restore = - scrollContainers.length > 0 - ? evaluateRuntime(page, "restoreViewportSnapshot").pipe( - Effect.catchCause((cause) => - Effect.logDebug("Viewport snapshot restoration failed", { cause }), - ), - ) - : Effect.void; - - const rawTree = yield* Effect.ensuring( - Effect.tryPromise({ - try: () => page.locator(selector).ariaSnapshot({ timeout }), - catch: (cause) => - new SnapshotTimeoutError({ - selector, - timeoutMs: timeout, - cause: cause instanceof Error ? cause.message : String(cause), - }), - }), - restore, - ); - - return { rawTree, scrollContainers }; - }); - - const snapshot = Effect.fn("Browser.snapshot")(function* ( - page: Page, - options: SnapshotOptions = {}, - ) { - yield* Effect.annotateCurrentSpan({ selector: options.selector ?? "body" }); - - const { rawTree, scrollContainers } = yield* takeAriaSnapshot(page, options); - - const refs: RefMap = {}; - const filteredLines: string[] = []; - let refCount = 0; - - for (const line of rawTree.split("\n")) { - if (options.maxDepth !== undefined && getIndentLevel(line) > options.maxDepth) continue; - - const parsed = parseAriaLine(line); - if (Option.isNone(parsed)) { - if (!options.interactive) filteredLines.push(line); - continue; - } - - const { role, name } = parsed.value; - if (options.interactive && !INTERACTIVE_ROLES.has(role)) continue; - - if (shouldAssignRef(role, name, options.interactive)) { - const ref = `${REF_PREFIX}${++refCount}`; - refs[ref] = { role, name }; - filteredLines.push(`${line} [ref=${ref}]`); - } else { - filteredLines.push(line); - } - } - - if (options.cursor) { - refCount = yield* appendCursorInteractiveElements( - page, - filteredLines, - refs, - refCount, - options, - ); - } - - resolveNthDuplicates(refs); - - let tree = filteredLines.join("\n"); - if (options.interactive && refCount === 0) tree = "(no interactive elements)"; - if (options.compact) tree = compactTree(tree); - - const stats = computeSnapshotStats(tree, refs, scrollContainers); - - return { tree, refs, stats, locator: createLocator(page, refs) }; - }); - - const act = Effect.fn("Browser.act")(function* ( - page: Page, - ref: string, - action: (locator: Locator) => Promise, - options?: SnapshotOptions, - ) { - yield* Effect.annotateCurrentSpan({ ref }); - const before = yield* snapshot(page, options); - const locator = yield* before.locator(ref); - yield* Effect.tryPromise({ - try: () => action(locator), - catch: (error) => toActionError(error, ref), - }); - return yield* snapshot(page, options); - }); - - const annotatedScreenshot = Effect.fn("Browser.annotatedScreenshot")(function* ( - page: Page, - options: AnnotatedScreenshotOptions = {}, - ) { - const snapshotResult = yield* snapshot(page, options); - const annotations: Annotation[] = []; - const labelPositions: Array<{ label: number; x: number; y: number }> = []; - - let labelCounter = 0; - - for (const [ref, entry] of Object.entries(snapshotResult.refs)) { - const locator = yield* snapshotResult.locator(ref); - const box = yield* Effect.tryPromise(() => locator.boundingBox()).pipe( - Effect.catchTag("UnknownError", () => Effect.succeed(undefined)), - ); - if (!box) continue; - - labelCounter++; - annotations.push({ label: labelCounter, ref, role: entry.role, name: entry.name }); - labelPositions.push({ label: labelCounter, x: box.x, y: box.y }); - } - - yield* injectOverlayLabels(page, labelPositions); - return yield* Effect.ensuring( - Effect.tryPromise({ - try: () => page.screenshot({ fullPage: options.fullPage, scale: "css" }), - catch: toBrowserLaunchError, - }).pipe(Effect.map((screenshotBuffer) => ({ screenshot: screenshotBuffer, annotations }))), - // HACK: overlay removal is best-effort cleanup — evaluateRuntime uses Effect.promise which defects on failure - evaluateRuntime(page, "removeOverlay", OVERLAY_CONTAINER_ID).pipe( - Effect.catchCause(() => Effect.void), - ), - ); - }); - - const waitForNavigationSettle = Effect.fn("Browser.waitForNavigationSettle")(function* ( - page: Page, - urlBefore: string, - ) { - yield* Effect.tryPromise({ - try: () => - page.waitForURL((url) => url.toString() !== urlBefore, { - timeout: NAVIGATION_DETECT_DELAY_MS, - waitUntil: "commit", - }), - catch: toBrowserLaunchError, - }).pipe(Effect.catchTag("BrowserLaunchError", () => Effect.void)); - if (page.url() !== urlBefore) { - yield* Effect.tryPromise(() => page.waitForLoadState("domcontentloaded")).pipe( - Effect.catchTag("UnknownError", () => Effect.void), - ); - yield* Effect.tryPromise({ - try: () => page.waitForTimeout(POST_NAVIGATION_SETTLE_MS), - catch: toBrowserLaunchError, - }); - } - }); - - const preExtractCookies = Effect.fn("Browser.preExtractCookies")(function* ( - browserKeys?: readonly string[], - ) { - if (browserKeys && browserKeys.length > 0) { - return yield* extractCookiesForBrowserKeys(browserKeys); - } - const { preferredProfile } = yield* resolveDefaultBrowserContext(); - return yield* extractDefaultBrowserCookies("", preferredProfile); - }); - - return { - createPage, - snapshot, - act, - annotatedScreenshot, - waitForNavigationSettle, - preExtractCookies, - } as const; - }), -}) { - static layer = Layer.effect(this)(this.make); -} - -export const runBrowser =
( - effect: (browser: typeof Browser.Service) => Effect.Effect, -): Promise => - Effect.runPromise( - Effect.gen(function* () { - const browser = yield* Browser; - return yield* effect(browser); - }).pipe(Effect.provide(Browser.layer)), - ); diff --git a/packages/browser/src/cdp-discovery.ts b/packages/browser/src/cdp-discovery.ts index da4dc0467..a8186481d 100644 --- a/packages/browser/src/cdp-discovery.ts +++ b/packages/browser/src/cdp-discovery.ts @@ -1,6 +1,6 @@ -import fs from "node:fs/promises"; +import * as fs from "node:fs/promises"; import os from "node:os"; -import path from "node:path"; +import * as path from "node:path"; import net from "node:net"; import { Effect, Option } from "effect"; import { CDP_DISCOVERY_TIMEOUT_MS, CDP_COMMON_PORTS, CDP_PORT_PROBE_TIMEOUT_MS } from "./constants"; diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts index 144569b81..7ca558ccb 100644 --- a/packages/browser/src/errors.ts +++ b/packages/browser/src/errors.ts @@ -3,7 +3,7 @@ import { Schema } from "effect"; export class BrowserLaunchError extends Schema.ErrorClass("BrowserLaunchError")( { _tag: Schema.tag("BrowserLaunchError"), - cause: Schema.String, + cause: Schema.Unknown, }, ) { message = `Failed to launch browser: ${this.cause}`; @@ -77,11 +77,52 @@ export class ActionUnknownError extends Schema.ErrorClass("A export class NavigationError extends Schema.ErrorClass("NavigationError")({ _tag: Schema.tag("NavigationError"), url: Schema.String, - cause: Schema.String, + cause: Schema.Unknown, }) { message = `Navigation to "${this.url}" failed: ${this.cause}`; } +export class BrowserAlreadyOpenError extends Schema.ErrorClass( + "BrowserAlreadyOpenError", +)({ + _tag: Schema.tag("BrowserAlreadyOpenError"), +}) { + message = "A browser is already open. Use the close tool first, then open a new session."; +} + +export class BrowserNotOpenError extends Schema.ErrorClass( + "BrowserNotOpenError", +)({ + _tag: Schema.tag("BrowserNotOpenError"), +}) { + message = "No browser is open. Use the open tool with a URL to start a browser session first."; +} + +export class NoSnapshotError extends Schema.ErrorClass("NoSnapshotError")({ + _tag: Schema.tag("NoSnapshotError"), +}) { + message = + "No snapshot taken yet. Use the screenshot tool with mode 'snapshot' first to capture element refs."; +} + +export class PlaywrightExecutionError extends Schema.ErrorClass( + "PlaywrightExecutionError", +)({ + _tag: Schema.tag("PlaywrightExecutionError"), + cause: Schema.Unknown, +}) { + message = `Playwright code execution failed: ${this.cause}`; +} + +export class McpServerStartError extends Schema.ErrorClass( + "McpServerStartError", +)({ + _tag: Schema.tag("McpServerStartError"), + cause: Schema.String, +}) { + message = `Failed to start MCP server: ${this.cause}. Check that no other browser MCP server is running on the same transport.`; +} + export class RecorderInjectionError extends Schema.ErrorClass( "RecorderInjectionError", )({ diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 40742be02..d116fa28b 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1,7 +1,8 @@ -export { Browser, runBrowser } from "./browser"; -export { buildReplayViewerHtml } from "./replay-viewer"; +export { Playwright, type OpenOptions } from "./playwright"; +export { Artifacts } from "./artifacts"; +export { ArtifactStorage } from "./artifact-storage"; +export { layerMcpServer } from "./mcp-server"; export { diffSnapshots } from "./diff"; -export { collectEvents, collectAllEvents, loadSession } from "./recorder"; export { autoDiscoverCdp, discoverCdpUrl } from "./cdp-discovery"; export { RrVideo, RrVideoConvertError } from "./rrvideo"; export type { @@ -13,9 +14,12 @@ export type { export { ActionTimeoutError, ActionUnknownError, + BrowserAlreadyOpenError, BrowserLaunchError, CdpConnectionError, CdpDiscoveryError, + BrowserNotOpenError, + McpServerStartError, NavigationError, RecorderInjectionError, RefAmbiguousError, @@ -31,9 +35,7 @@ export type { AnnotatedScreenshotOptions, AnnotatedScreenshotResult, AriaRole, - BrowserEngine, CollectResult, - CreatePageOptions, RefEntry, RefMap, SnapshotDiff, diff --git a/packages/browser/src/mcp-server.ts b/packages/browser/src/mcp-server.ts new file mode 100644 index 000000000..a5fde398c --- /dev/null +++ b/packages/browser/src/mcp-server.ts @@ -0,0 +1,491 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { z } from "zod/v4"; +import { Config, Effect, Layer, Option, Schema, ServiceMap } from "effect"; +import { BrowserJson } from "@expect/cookies"; +import { PerformanceTrace } from "@expect/shared/models"; +import type { ConsoleLog, NetworkRequest } from "@expect/shared/models"; +import { + DUPLICATE_REQUEST_WINDOW_MS, + EXPECT_BROWSER_PROFILE_ENV_NAME, + EXPECT_HEADED_ENV_NAME, +} from "./mcp/constants"; + +import { Playwright, PlaywrightSession } from "./playwright"; +import { Artifacts } from "./artifacts"; +import { McpServerStartError, NoSnapshotError } from "./errors"; +import { evaluateRuntime } from "./utils/evaluate-runtime"; +import type { SnapshotResult } from "./types"; +import { autoDiscoverCdp } from "./cdp-discovery"; +import { hasStringMessage } from "@expect/shared/utils"; +import { formatPerformanceTrace } from "./performance-trace"; + +export const McpTransport = ServiceMap.Reference("@browser/McpTransport", { + defaultValue: () => new StdioServerTransport(), +}); + +const textResult = (text: string) => ({ + content: [{ type: "text" as const, text }], +}); + +const safeJsonStringify = (data: unknown): string => { + const seen = new WeakSet(); + return JSON.stringify( + data, + (_key, value) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + } + return value; + }, + 2, + ); +}; + +const jsonResult = (data: unknown) => textResult(safeJsonStringify(data)); + +const imageResult = (base64: string) => ({ + content: [{ type: "image" as const, data: base64, mimeType: "image/png" }], +}); + +const errorResult = (error: unknown) => ({ + content: [ + { + type: "text" as const, + text: hasStringMessage(error) ? error.message : String(error), + }, + ], + isError: true as const, +}); + +const decodeBrowserProfile = Schema.decodeEffect(BrowserJson); + +export const layerMcpServer = Layer.effectDiscard( + Effect.gen(function* () { + const services = yield* Effect.services(); + const runRaw = Effect.runPromiseWith(services); + const run = ( + effect: Effect.Effect, + options: { + signal?: AbortSignal; + method: string; + attributes?: Record; + }, + ) => + runRaw( + effect.pipe( + Effect.tapCause((cause) => + Effect.logError(`An error occurred in ${options.method}`, cause), + ), + Effect.annotateLogs({ method: `McpServer.${options?.method}` }), + Effect.withSpan(`McpServer.${options.method}`, { + attributes: options.attributes, + }), + ), + options, + ).catch((error) => errorResult(error)); + + const browserProfileJson = yield* Config.string(EXPECT_BROWSER_PROFILE_ENV_NAME).pipe( + Config.option, + ); + + const browserProfile = yield* Option.match(browserProfileJson, { + onNone: () => Effect.succeedNone, + onSome: (json) => decodeBrowserProfile(json).pipe(Effect.map(Option.some), Effect.orDie), + }); + + Config.boolean(); + + const forceHeaded = yield* Config.boolean(EXPECT_HEADED_ENV_NAME).pipe( + Config.withDefault(false), + ); + + const server = new McpServer({ name: "expect", version: "0.0.1" }); + + let lastSnapshot: SnapshotResult | undefined; + + // open + server.registerTool( + "open", + { + title: "Open URL", + description: "Navigate to a URL, launching a browser if needed.", + inputSchema: { + url: z.string().describe("URL to navigate to"), + headed: z.boolean().optional().describe("Show browser window"), + cookies: z + .boolean() + .optional() + .describe("Reuse local browser cookies for the target URL when available"), + waitUntil: z + .enum(["load", "domcontentloaded", "networkidle", "commit"]) + .optional() + .describe("Wait strategy"), + cdp: z + .string() + .optional() + .describe( + "CDP WebSocket endpoint URL to connect to an existing Chrome instance (e.g. 'ws://localhost:9222/devtools/browser/...'). Use 'auto' to auto-discover a running Chrome.", + ), + browser: z + .enum(["chromium", "webkit", "firefox"]) + .optional() + .describe("Browser engine to launch (default: chromium)."), + }, + }, + ({ url, headed, cookies, waitUntil, cdp, browser: browserOverride }, { signal }) => + Effect.gen(function* () { + const pw = yield* Playwright; + if (pw.hasSession()) { + yield* pw.navigate(url, { waitUntil }); + return textResult(`Navigated to ${url}`); + } + + yield* pw.open({ + headless: forceHeaded ? false : !headed, + browserOverride, + browserProfile, + initialNavigation: Option.some({ + url, + waitUntil, + }), + cdpUrl: + cdp === "auto" + ? yield* autoDiscoverCdp().pipe(Effect.option) + : Option.fromNullishOr(cdp), + }); + const browserSuffix = browserOverride ? ` [${browserOverride}]` : ""; + return textResult(`Opened ${url}${browserSuffix}`); + }).pipe((effect) => + run(effect, { + signal, + method: "open", + attributes: { url, headed, waitUntil, cdp }, + }), + ), + ); + + // screenshot + server.registerTool( + "screenshot", + { + title: "Screenshot", + description: + "Capture the current page state. Modes: 'screenshot' (default, PNG image), 'snapshot' (ARIA accessibility tree with element refs), 'annotated' (screenshot with numbered labels on interactive elements).", + annotations: { readOnlyHint: true }, + inputSchema: { + mode: z + .enum(["screenshot", "snapshot", "annotated"]) + .optional() + .describe("Capture mode (default: screenshot)"), + fullPage: z.boolean().optional().describe("Capture the full scrollable page"), + }, + }, + ({ mode, fullPage }, { signal }) => + Effect.gen(function* () { + const pw = yield* Playwright; + const resolvedMode = mode ?? "screenshot"; + + if (resolvedMode === "snapshot") { + const result = yield* pw.snapshot({}); + lastSnapshot = result; + return jsonResult({ + tree: result.tree, + refs: result.refs, + stats: result.stats, + }); + } + + if (resolvedMode === "annotated") { + const result = yield* pw.annotatedScreenshot({ fullPage }); + return { + content: [ + { + type: "image" as const, + data: result.screenshot.toString("base64"), + mimeType: "image/png", + }, + { + type: "text" as const, + text: result.annotations + .map( + (annotation) => + `[${annotation.label}] @${annotation.ref} ${annotation.role} "${annotation.name}"`, + ) + .join("\n"), + }, + ], + }; + } + + const page = yield* pw.getPage; + const buffer = yield* Effect.tryPromise(() => page.screenshot({ fullPage })); + return imageResult(buffer.toString("base64")); + }).pipe((effect) => + run(effect, { + signal, + method: "screenshot", + attributes: { mode, fullPage }, + }), + ), + ); + + // playwright — raw code execution + server.registerTool( + "playwright", + { + title: "Execute Playwright", + description: + "Execute Playwright code in the Node.js context. Available globals: page (Page), context (BrowserContext), browser (Browser), ref (function: ref ID from snapshot → Playwright Locator). Use `return` to send a value back as JSON. Supports await. Set snapshotAfter=true to automatically take a fresh ARIA snapshot after execution.", + inputSchema: { + code: z.string().describe("Playwright code to execute"), + snapshotAfter: z + .boolean() + .optional() + .describe("Take a fresh snapshot after execution and return it alongside the result"), + }, + }, + ({ code, snapshotAfter }, { signal }) => + Effect.gen(function* () { + const pw = yield* Playwright; + if (!lastSnapshot) return yield* new NoSnapshotError(); + const result = yield* pw.execute(code, lastSnapshot); + + if (!snapshotAfter) { + if (result === undefined) return textResult("OK"); + return jsonResult(result); + } + + const fresh = yield* pw.snapshot({}); + lastSnapshot = fresh; + const snapshot = { + tree: fresh.tree, + refs: fresh.refs, + stats: fresh.stats, + }; + return jsonResult(result === undefined ? { snapshot } : { result, snapshot }); + }).pipe((effect) => run(effect, { signal, method: "playwright" })), + ); + + // console_logs + server.registerTool( + "console_logs", + { + title: "Console Logs", + description: + "Get browser console log messages. Optionally filter by log type (log, warning, error, info, debug).", + annotations: { readOnlyHint: true }, + inputSchema: { + type: z + .string() + .optional() + .describe("Filter by console message type (e.g. 'error', 'warning', 'log')"), + }, + }, + ({ type }, { signal }) => + Effect.gen(function* () { + const artifacts = yield* Artifacts; + const logs = artifacts + .all() + .filter((entry): entry is ConsoleLog => entry._tag === "ConsoleLog"); + const filtered = type ? logs.filter((entry) => entry.type === type) : logs; + return filtered.length === 0 + ? textResult("No console messages captured.") + : jsonResult(filtered); + }).pipe((effect) => run(effect, { signal, method: "console_logs", attributes: { type } })), + ); + + // network_requests + server.registerTool( + "network_requests", + { + title: "Network Requests", + description: + "Get captured network requests with automatic issue detection. Flags failed requests (4xx/5xx), duplicate requests (same URL+method within 500ms), and mixed content (HTTP on HTTPS pages). Optionally filter by HTTP method, URL substring, or resource type.", + annotations: { readOnlyHint: true }, + inputSchema: { + method: z.string().optional().describe("Filter by HTTP method (e.g. 'GET', 'POST')"), + url: z.string().optional().describe("Filter by URL substring match"), + resourceType: z + .string() + .optional() + .describe("Filter by resource type (e.g. 'xhr', 'fetch', 'document', 'script')"), + clear: z.boolean().optional().describe("Clear the collected requests after reading"), + }, + }, + ({ method, url, resourceType, clear }, { signal }) => + Effect.gen(function* () { + const artifacts = yield* Artifacts; + const allRequests = artifacts + .all() + .filter((entry): entry is NetworkRequest => entry._tag === "NetworkRequest"); + + const normalizedMethod = method?.toUpperCase(); + const normalizedResourceType = resourceType?.toLowerCase(); + const entries = allRequests.filter( + (entry) => + (!normalizedMethod || entry.method === normalizedMethod) && + (!url || entry.url.includes(url)) && + (!normalizedResourceType || entry.resourceType === normalizedResourceType), + ); + + if (clear) artifacts.clear(); + if (entries.length === 0) return textResult("No network requests captured."); + + const failed = entries.filter( + (entry) => entry.status !== undefined && entry.status >= 400, + ); + + const duplicateMap = new Map(); + const lastTimestamp = new Map(); + for (const entry of entries) { + const key = `${entry.method}:${entry.url}`; + const previous = lastTimestamp.get(key); + if ( + previous !== undefined && + Math.abs(entry.timestamp - previous) < DUPLICATE_REQUEST_WINDOW_MS + ) { + const existing = duplicateMap.get(key); + if (existing) { + existing.count++; + } else { + duplicateMap.set(key, { + url: entry.url, + method: entry.method, + count: 2, + }); + } + } + lastTimestamp.set(key, entry.timestamp); + } + const duplicates = Array.from(duplicateMap.values()); + + const isHttps = entries.some( + (entry) => entry.resourceType === "document" && entry.url.startsWith("https://"), + ); + const mixedContent = isHttps + ? entries.filter( + (entry) => entry.resourceType !== "document" && entry.url.startsWith("http://"), + ) + : []; + + const issues = { + failedRequests: failed.map((entry) => ({ + url: entry.url, + method: entry.method, + status: entry.status, + })), + duplicateRequests: duplicates, + mixedContent: mixedContent.map((entry) => entry.url), + }; + + const hasIssues = failed.length > 0 || duplicates.length > 0 || mixedContent.length > 0; + + return jsonResult({ + issues: hasIssues ? issues : undefined, + requests: entries, + }); + }).pipe((effect) => + run(effect, { + signal, + method: "network_requests", + attributes: { method, url, resourceType }, + }), + ), + ); + + // performance_metrics + server.registerTool( + "performance_metrics", + { + title: "Performance Metrics", + description: + "Collect a full performance trace: Core Web Vitals (FCP, LCP, CLS, INP), navigation timing (TTFB, server timing), Long Animation Frames (LoAF) with script attribution, and resource breakdown (slowest/largest). Pushes the full trace as an artifact and returns a summary. Read the artifact for detailed LoAF script attribution and resource analysis.", + annotations: { readOnlyHint: true }, + inputSchema: {}, + }, + (_, { signal }) => + Effect.gen(function* () { + const pw = yield* Playwright; + const artifacts = yield* Artifacts; + const page = yield* pw.getPage; + const trace = yield* evaluateRuntime(page, "getPerformanceTrace"); + + const hasMetrics = trace.webVitals.fcp || trace.webVitals.lcp || trace.webVitals.inp; + if (!hasMetrics && trace.longAnimationFrames.length === 0) { + return textResult("No performance metrics available yet."); + } + + const traceDocument = formatPerformanceTrace(trace); + yield* artifacts.push([new PerformanceTrace({ trace: traceDocument })]); + + const summary = ["Performance trace pushed as artifact.", "", "Web Vitals:"]; + const { webVitals } = trace; + if (webVitals.fcp) + summary.push(` FCP: ${webVitals.fcp.value}ms (${webVitals.fcp.rating})`); + if (webVitals.lcp) + summary.push(` LCP: ${webVitals.lcp.value}ms (${webVitals.lcp.rating})`); + if (webVitals.cls) + summary.push(` CLS: ${webVitals.cls.value} (${webVitals.cls.rating})`); + if (webVitals.inp) + summary.push(` INP: ${webVitals.inp.value}ms (${webVitals.inp.rating})`); + if (trace.navigation) { + summary.push(` TTFB: ${trace.navigation.ttfb}ms`); + } + if (trace.longAnimationFrames.length > 0) { + summary.push(`\nLong Animation Frames: ${trace.longAnimationFrames.length} detected`); + const worstBlocking = Math.max( + ...trace.longAnimationFrames.map((frame) => frame.blockingDuration), + ); + summary.push(` Worst blocking duration: ${Math.round(worstBlocking)}ms`); + } + summary.push( + `\nResources: ${trace.resources.totalCount} loaded (${Math.round( + trace.resources.totalTransferSizeBytes / 1024, + )}KB total)`, + ); + + return textResult(summary.join("\n")); + }).pipe((effect) => run(effect, { signal, method: "performance_metrics" })), + ); + + // close + server.registerTool( + "close", + { + title: "Close Browser", + description: "Close the browser and end the session.", + annotations: { destructiveHint: true }, + inputSchema: {}, + }, + (_, { signal }) => + Effect.gen(function* () { + const pw = yield* Playwright; + if (!pw.hasSession()) { + return textResult("No browser open."); + } + yield* pw.close(); + lastSnapshot = undefined; + return textResult("Browser closed."); + }).pipe((effect) => run(effect, { signal, method: "close" })), + ); + + const transport = yield* McpTransport; + yield* Effect.logInfo(`Starting MCP server`); + yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => server.connect(transport), + catch: (cause) => + new McpServerStartError({ + cause: cause instanceof Error ? cause.message : String(cause), + }), + }), + () => + Effect.tryPromise(() => server.close()).pipe( + Effect.ignore({ message: "Failed to close MCP server", log: "Warn" }), + ), + ); + }), +).pipe(Layer.provide(Playwright.layer)); diff --git a/packages/browser/src/mcp/artifact-client.ts b/packages/browser/src/mcp/artifact-client.ts new file mode 100644 index 000000000..a4b09cd4b --- /dev/null +++ b/packages/browser/src/mcp/artifact-client.ts @@ -0,0 +1,23 @@ +import { Layer, ServiceMap, Effect } from "effect"; +import { NodeSocket } from "@effect/platform-node"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { RpcSerialization } from "effect/unstable/rpc"; +import { ArtifactRpcs } from "@expect/shared/rpcs"; +import { LIVE_VIEWER_RPC_URL } from "@expect/shared"; + +type Client = RpcClient.RpcClient, RpcClientError>; + +const protocolLayer = RpcClient.layerProtocolSocket().pipe( + Layer.provide(NodeSocket.layerWebSocket(LIVE_VIEWER_RPC_URL)), + Layer.provide(RpcSerialization.layerNdjson), +); + +export class ArtifactClient extends ServiceMap.Service()( + "@browser/ArtifactClient", +) { + static layer = Layer.effect(ArtifactClient)(RpcClient.make(ArtifactRpcs)).pipe( + Layer.provide(protocolLayer), + ); +} diff --git a/packages/browser/src/mcp/constants.ts b/packages/browser/src/mcp/constants.ts index 4aa5f9a1f..237acddb8 100644 --- a/packages/browser/src/mcp/constants.ts +++ b/packages/browser/src/mcp/constants.ts @@ -1,7 +1,7 @@ -export const EXPECT_LIVE_VIEW_URL_ENV_NAME = "EXPECT_LIVE_VIEW_URL"; export const EXPECT_REPLAY_OUTPUT_ENV_NAME = "EXPECT_REPLAY_OUTPUT_PATH"; -export const EXPECT_COOKIE_BROWSERS_ENV_NAME = "EXPECT_COOKIE_BROWSERS"; +export const EXPECT_BROWSER_PROFILE_ENV_NAME = "EXPECT_BROWSER_PROFILE"; export const EXPECT_CDP_URL_ENV_NAME = "EXPECT_CDP_URL"; export const EXPECT_BASE_URL_ENV_NAME = "EXPECT_BASE_URL"; +export const EXPECT_HEADED_ENV_NAME = "EXPECT_HEADED"; export const LIVE_VIEW_PAGE_POLL_INTERVAL_MS = 500; export const DUPLICATE_REQUEST_WINDOW_MS = 500; diff --git a/packages/browser/src/mcp/index.ts b/packages/browser/src/mcp/index.ts index 42496c036..1d86542f5 100644 --- a/packages/browser/src/mcp/index.ts +++ b/packages/browser/src/mcp/index.ts @@ -1,10 +1,8 @@ +export { layerMcpServer, McpTransport } from "../mcp-server"; export { - EXPECT_LIVE_VIEW_URL_ENV_NAME, - EXPECT_COOKIE_BROWSERS_ENV_NAME, + EXPECT_BROWSER_PROFILE_ENV_NAME, + EXPECT_HEADED_ENV_NAME, EXPECT_REPLAY_OUTPUT_ENV_NAME, EXPECT_BASE_URL_ENV_NAME, } from "./constants"; -export { McpSession } from "./mcp-session"; -export { McpRuntime } from "./runtime"; -export { createBrowserMcpServer, startBrowserMcpServer } from "./server"; export type { ViewerRunState, ViewerStepEvent } from "./viewer-events"; diff --git a/packages/browser/src/mcp/live-view-server.ts b/packages/browser/src/mcp/live-view-server.ts deleted file mode 100644 index dfabe7c8a..000000000 --- a/packages/browser/src/mcp/live-view-server.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import type { Page } from "playwright"; -import type { eventWithTime } from "@rrweb/types"; -import { Effect, Fiber, Predicate, PubSub, Schedule, Stream } from "effect"; -import { EVENT_COLLECT_INTERVAL_MS } from "../constants"; -import { buildReplayViewerHtml } from "../replay-viewer"; -import { evaluateRuntime } from "../utils/evaluate-runtime"; -import type { ViewerRunState } from "./viewer-events"; - -const isViewerRunState = (value: unknown): value is ViewerRunState => - Predicate.isObject(value) && - "status" in value && - "steps" in value && - Array.isArray((value as Record).steps); - -export interface LiveViewHandle { - readonly url: string; - readonly pushRunState: (state: ViewerRunState) => void; - readonly getLatestRunState: () => ViewerRunState | undefined; - readonly close: Effect.Effect; -} - -export interface StartLiveViewServerOptions { - readonly liveViewUrl: string; - readonly getPage: () => Page | undefined; - readonly onEventsCollected: (events: eventWithTime[]) => void; -} - -type SseClient = ServerResponse; - -const CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", -} as const; - -const NO_CACHE_HEADERS = { "Cache-Control": "no-store" } as const; - -const listenServer = (server: Server, host: string, port: number) => - Effect.callback((resume) => { - const onError = (error: Error) => resume(Effect.fail(error)); - server.once("error", onError); - server.listen({ host, port }, () => { - server.off("error", onError); - resume(Effect.void); - }); - }); - -const closeServer = (server: Server) => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - }); - -export const startLiveViewServer = Effect.fn("LiveViewServer.start")(function* ( - options: StartLiveViewServerOptions, -) { - const parsedUrl = new URL(options.liveViewUrl); - const sseClients = new Set(); - const accumulatedReplayEvents: eventWithTime[] = []; - let latestRunState: ViewerRunState | undefined; - - const stepsPubSub = yield* PubSub.unbounded(); - - const viewerHtml = buildReplayViewerHtml({ - title: "Expect Live View", - eventsSource: "sse", - }); - - const broadcastSse = (eventType: string, data: string): void => { - const message = `event: ${eventType}\ndata: ${data}\n\n`; - for (const client of sseClients) { - if (client.destroyed) { - sseClients.delete(client); - continue; - } - try { - client.write(message); - } catch { - sseClients.delete(client); - client.end(); - } - } - }; - - const broadcastReplayEvents = (events: eventWithTime[]): void => { - if (events.length === 0) return; - accumulatedReplayEvents.push(...events); - options.onEventsCollected(events); - broadcastSse("replay", JSON.stringify(events)); - }; - - const broadcastRunState = (state: ViewerRunState): void => { - latestRunState = state; - broadcastSse("steps", JSON.stringify(state)); - }; - - const handleSseRequest = (request: IncomingMessage, response: SseClient): void => { - response.writeHead(200, { - "Content-Type": "text/event-stream", - Connection: "keep-alive", - ...CORS_HEADERS, - ...NO_CACHE_HEADERS, - }); - response.flushHeaders(); - sseClients.add(response); - request.on("close", () => sseClients.delete(response)); - }; - - const handleStepsPost = (request: IncomingMessage, response: SseClient): void => { - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - try { - const body = Buffer.concat(chunks).toString("utf-8"); - const parsed: unknown = JSON.parse(body); - if (!isViewerRunState(parsed)) { - response.writeHead(400, { - "Content-Type": "text/plain", - ...CORS_HEADERS, - ...NO_CACHE_HEADERS, - }); - response.end("Invalid step state: missing status or steps"); - return; - } - broadcastRunState(parsed); - response.writeHead(204, { ...CORS_HEADERS, ...NO_CACHE_HEADERS }); - response.end(); - } catch { - response.writeHead(400, { - "Content-Type": "text/plain", - ...CORS_HEADERS, - ...NO_CACHE_HEADERS, - }); - response.end("Invalid JSON"); - } - }); - }; - - const routeRequest = (request: IncomingMessage, response: SseClient): void => { - if (request.method === "OPTIONS") { - response.writeHead(204, CORS_HEADERS); - response.end(); - return; - } - - const pathname = new URL(request.url ?? "/", parsedUrl).pathname; - - if (pathname === "/") { - response.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - ...CORS_HEADERS, - ...NO_CACHE_HEADERS, - }); - response.end(viewerHtml); - return; - } - - if (pathname === "/events") { - handleSseRequest(request, response); - return; - } - - if (pathname === "/latest.json") { - const body = JSON.stringify(accumulatedReplayEvents); - response.writeHead(200, { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - ...CORS_HEADERS, - ...NO_CACHE_HEADERS, - }); - response.end(body); - return; - } - - if (pathname === "/steps") { - if (request.method === "POST") { - handleStepsPost(request, response); - return; - } - const body = JSON.stringify(latestRunState ?? { title: "", status: "running", steps: [] }); - response.writeHead(200, { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - ...CORS_HEADERS, - ...NO_CACHE_HEADERS, - }); - response.end(body); - return; - } - - response.writeHead(404, { - "Content-Type": "text/plain; charset=utf-8", - ...CORS_HEADERS, - ...NO_CACHE_HEADERS, - }); - response.end("Not found"); - }; - - const server = createServer(routeRequest); - - yield* listenServer(server, parsedUrl.hostname, Number(parsedUrl.port)); - - const pollPage = Effect.sync(() => options.getPage()).pipe( - Effect.flatMap((page) => { - if (!page || page.isClosed()) return Effect.void; - return evaluateRuntime(page, "startRecording").pipe( - Effect.catchCause(() => Effect.void), - Effect.flatMap(() => evaluateRuntime(page, "getEvents")), - Effect.tap((events) => - Effect.sync(() => { - if (Array.isArray(events) && events.length > 0) { - broadcastReplayEvents(events); - } - }), - ), - Effect.catchCause((cause) => Effect.logDebug("Replay event collection failed", { cause })), - ); - }), - ); - - const replayPollFiber = yield* pollPage.pipe( - Effect.repeat(Schedule.spaced(EVENT_COLLECT_INTERVAL_MS)), - Effect.forkDetach, - ); - - const stepsBroadcastFiber = yield* Stream.fromPubSub(stepsPubSub).pipe( - Stream.tap((state) => Effect.sync(() => broadcastRunState(state))), - Stream.runDrain, - Effect.forkDetach, - ); - - return { - url: parsedUrl.toString(), - pushRunState: (state: ViewerRunState) => { - PubSub.publishUnsafe(stepsPubSub, state); - }, - getLatestRunState: () => latestRunState, - close: Effect.gen(function* () { - yield* Fiber.interrupt(replayPollFiber); - yield* Fiber.interrupt(stepsBroadcastFiber); - for (const client of sseClients) client.end(); - sseClients.clear(); - yield* closeServer(server); - }), - } satisfies LiveViewHandle; -}); diff --git a/packages/browser/src/mcp/mcp-session.ts b/packages/browser/src/mcp/mcp-session.ts deleted file mode 100644 index f1c6604b7..000000000 --- a/packages/browser/src/mcp/mcp-session.ts +++ /dev/null @@ -1,596 +0,0 @@ -import path from "node:path"; -import type { Browser as PlaywrightBrowser, BrowserContext, Page } from "playwright"; -import type { eventWithTime } from "@rrweb/types"; -import { Config, Effect, Fiber, Layer, Option, Ref, Schedule, ServiceMap } from "effect"; -import type { Cookie } from "@expect/cookies"; -import { FileSystem } from "effect/FileSystem"; -import { Browser } from "../browser"; -import { NavigationError } from "../errors"; -import { collectAllEvents } from "../recorder"; -import { evaluateRuntime } from "../utils/evaluate-runtime"; -import { EVENT_COLLECT_INTERVAL_MS } from "../constants"; -import { buildReplayViewerHtml } from "../replay-viewer"; -import type { - AnnotatedScreenshotOptions, - BrowserEngine, - SnapshotOptions, - SnapshotResult, -} from "../types"; -import { - EXPECT_LIVE_VIEW_URL_ENV_NAME, - EXPECT_COOKIE_BROWSERS_ENV_NAME, - EXPECT_REPLAY_OUTPUT_ENV_NAME, - EXPECT_CDP_URL_ENV_NAME, - EXPECT_BASE_URL_ENV_NAME, -} from "./constants"; -import { McpSessionNotOpenError } from "./errors"; -import { startLiveViewServer, type LiveViewHandle } from "./live-view-server"; -import type { ViewerRunState } from "./viewer-events"; - -interface ConsoleEntry { - readonly type: string; - readonly text: string; - readonly timestamp: number; -} - -interface NetworkEntry { - readonly url: string; - readonly method: string; - status: number | undefined; - readonly resourceType: string; - readonly timestamp: number; -} - -export interface BrowserSessionData { - readonly browser: PlaywrightBrowser; - readonly context: BrowserContext; - readonly page: Page; - readonly consoleMessages: ConsoleEntry[]; - readonly networkRequests: NetworkEntry[]; - readonly replayOutputPath: string | undefined; - readonly accumulatedReplayEvents: eventWithTime[]; - readonly trackedPages: Set; - lastSnapshot: SnapshotResult | undefined; -} - -export interface OpenOptions { - headed?: boolean; - cookies?: boolean; - waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit"; - cdpUrl?: string; - browserType?: BrowserEngine; -} - -export interface OpenResult { - readonly injectedCookieCount: number; -} - -export interface CloseResult { - readonly replaySessionPath: string | undefined; - readonly reportPath: string | undefined; - readonly videoPath: string | undefined; - readonly tmpReplaySessionPath: string | undefined; - readonly tmpReportPath: string | undefined; - readonly tmpVideoPath: string | undefined; - readonly screenshotPaths: readonly string[]; -} - -const TMP_ARTIFACT_OUTPUT_DIRECTORY = "/tmp/expect-replays"; -const PLAYWRIGHT_VIDEO_SUBDIRECTORY = "playwright"; - -const setupPageTracking = (page: Page, sessionData: BrowserSessionData) => { - if (sessionData.trackedPages.has(page)) return; - sessionData.trackedPages.add(page); - - page.on("console", (message) => { - sessionData.consoleMessages.push({ - type: message.type(), - text: message.text(), - timestamp: Date.now(), - }); - }); - - page.on("request", (request) => { - sessionData.networkRequests.push({ - url: request.url(), - method: request.method(), - status: undefined, - resourceType: request.resourceType(), - timestamp: Date.now(), - }); - }); - - page.on("response", (response) => { - const entry = sessionData.networkRequests.find( - (networkEntry) => networkEntry.url === response.url() && networkEntry.status === undefined, - ); - if (entry) entry.status = response.status(); - }); -}; - -export class McpSession extends ServiceMap.Service()("@browser/McpSession", { - make: Effect.gen(function* () { - const browserService = yield* Browser; - const fileSystem = yield* FileSystem; - const replayOutputPath = yield* Config.option(Config.string(EXPECT_REPLAY_OUTPUT_ENV_NAME)); - const liveViewUrl = yield* Config.option(Config.string(EXPECT_LIVE_VIEW_URL_ENV_NAME)); - const cookieBrowsersConfig = yield* Config.option( - Config.string(EXPECT_COOKIE_BROWSERS_ENV_NAME), - ); - const cdpUrlConfig = yield* Config.option(Config.string(EXPECT_CDP_URL_ENV_NAME)); - const defaultCdpUrl = Option.getOrUndefined(cdpUrlConfig); - const baseUrlConfig = yield* Config.option(Config.string(EXPECT_BASE_URL_ENV_NAME)); - const configuredBaseUrl = Option.getOrUndefined(baseUrlConfig); - const cookieBrowserKeys = Option.match(cookieBrowsersConfig, { - onNone: () => [] as string[], - onSome: (value) => value.split(",").filter(Boolean), - }); - const cookiesDisabled = cookieBrowserKeys.length === 0; - - const sessionRef = yield* Ref.make(undefined); - const liveViewRef = yield* Ref.make(undefined); - const pollingFiberRef = yield* Ref.make | undefined>(undefined); - const latestRunStateRef = yield* Ref.make(undefined); - const preExtractedCookiesRef = yield* Ref.make(undefined); - const savedScreenshotPathsRef = yield* Ref.make([]); - - const saveScreenshot = Effect.fn("McpSession.saveScreenshot")(function* (buffer: Buffer) { - const currentPaths = yield* Ref.get(savedScreenshotPathsRef); - const screenshotIndex = currentPaths.length; - const screenshotPath = path.join( - TMP_ARTIFACT_OUTPUT_DIRECTORY, - `screenshot-${screenshotIndex}.png`, - ); - yield* fileSystem - .makeDirectory(TMP_ARTIFACT_OUTPUT_DIRECTORY, { recursive: true }) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to create screenshot directory", { cause }), - ), - ); - yield* fileSystem.writeFile(screenshotPath, new Uint8Array(buffer)).pipe( - Effect.tap(() => - Ref.update(savedScreenshotPathsRef, (paths) => [...paths, screenshotPath]), - ), - Effect.tap(() => - Effect.logDebug("Screenshot saved", { path: screenshotPath, index: screenshotIndex }), - ), - Effect.catchCause((cause) => Effect.logWarning("Failed to save screenshot", { cause })), - ); - }); - - if (!cookiesDisabled) { - yield* browserService.preExtractCookies(cookieBrowserKeys).pipe( - Effect.tap((cookies) => Ref.set(preExtractedCookiesRef, cookies)), - Effect.tap((cookies) => Effect.logInfo("Cookies pre-extracted", { count: cookies.length })), - Effect.catchCause((cause) => Effect.logWarning("Cookie pre-extraction failed", { cause })), - Effect.forkDetach, - ); - } - - const resolveUrl = (url: string): string => { - if (configuredBaseUrl && !url.startsWith("http://") && !url.startsWith("https://")) { - try { - return new URL(url, configuredBaseUrl).toString(); - } catch { - return url; - } - } - return url; - }; - - const requireSession = Effect.fn("McpSession.requireSession")(function* () { - const session = yield* Ref.get(sessionRef); - if (!session) return yield* new McpSessionNotOpenError(); - return session; - }); - - const requirePage = Effect.fn("McpSession.requirePage")(function* () { - return (yield* requireSession()).page; - }); - - const navigate = Effect.fn("McpSession.navigate")(function* ( - url: string, - options: { waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit" } = {}, - ) { - const resolved = resolveUrl(url); - const sessionData = yield* requireSession(); - yield* Effect.tryPromise({ - try: () => sessionData.page.goto(resolved, { waitUntil: options.waitUntil ?? "load" }), - catch: (cause) => - new NavigationError({ - url: resolved, - cause: cause instanceof Error ? cause.message : String(cause), - }), - }); - }); - - const pushStepEvent = Effect.fn("McpSession.pushStepEvent")(function* (state: ViewerRunState) { - yield* Ref.set(latestRunStateRef, state); - const liveView = yield* Ref.get(liveViewRef); - if (liveView) { - liveView.pushRunState(state); - } - }); - - const open = Effect.fn("McpSession.open")(function* ( - rawUrl: string, - options: OpenOptions = {}, - ) { - const url = resolveUrl(rawUrl); - yield* Effect.annotateCurrentSpan({ url }); - yield* Ref.set(savedScreenshotPathsRef, []); - - const preExtracted = options.cookies ? yield* Ref.get(preExtractedCookiesRef) : undefined; - const cookiesOption = - preExtracted && preExtracted.length > 0 ? preExtracted : options.cookies; - const videoOutputDir = path.join( - TMP_ARTIFACT_OUTPUT_DIRECTORY, - PLAYWRIGHT_VIDEO_SUBDIRECTORY, - ); - - yield* fileSystem - .makeDirectory(videoOutputDir, { recursive: true }) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to create Playwright video directory", { cause }), - ), - ); - - const pageResult = yield* browserService.createPage(url, { - headed: options.headed, - cookies: cookiesOption, - waitUntil: options.waitUntil, - videoOutputDir, - cdpUrl: options.cdpUrl ?? defaultCdpUrl, - browserType: options.browserType, - }); - - const sessionData: BrowserSessionData = { - browser: pageResult.browser, - context: pageResult.context, - page: pageResult.page, - consoleMessages: [], - networkRequests: [], - replayOutputPath: Option.getOrUndefined(replayOutputPath), - accumulatedReplayEvents: [], - trackedPages: new Set(), - lastSnapshot: undefined, - }; - setupPageTracking(pageResult.page, sessionData); - yield* Ref.set(sessionRef, sessionData); - - yield* evaluateRuntime(pageResult.page, "startRecording").pipe( - Effect.catchCause((cause) => Effect.logDebug("rrweb recording failed to start", { cause })), - ); - - const existingLiveView = yield* Ref.get(liveViewRef); - if (Option.isSome(liveViewUrl) && !existingLiveView) { - const handle = yield* startLiveViewServer({ - liveViewUrl: liveViewUrl.value, - getPage: () => Ref.getUnsafe(sessionRef)?.page, - onEventsCollected: (events) => { - Ref.getUnsafe(sessionRef)?.accumulatedReplayEvents.push(...events); - }, - }).pipe( - Effect.catchCause((cause) => - Effect.logDebug("Live view server failed to start", { cause }).pipe( - Effect.as(undefined), - ), - ), - ); - if (handle) { - yield* Ref.set(liveViewRef, handle); - } - } - - const hasLiveView = Boolean(yield* Ref.get(liveViewRef)); - if (!hasLiveView) { - const pollPage = Effect.sync(() => Ref.getUnsafe(sessionRef)?.page).pipe( - Effect.flatMap((page) => { - if (!page || page.isClosed()) return Effect.void; - return evaluateRuntime(page, "startRecording").pipe( - Effect.catchCause(() => Effect.void), - Effect.flatMap(() => evaluateRuntime(page, "getEvents")), - Effect.tap((events) => - Effect.sync(() => { - if (Array.isArray(events) && events.length > 0) { - Ref.getUnsafe(sessionRef)?.accumulatedReplayEvents.push(...events); - } - }), - ), - Effect.catchCause((cause) => - Effect.logDebug("Replay event collection failed", { cause }), - ), - ); - }), - ); - - const fiber = yield* pollPage.pipe( - Effect.repeat(Schedule.spaced(EVENT_COLLECT_INTERVAL_MS)), - Effect.forkDetach, - ); - yield* Ref.set(pollingFiberRef, fiber); - } - - const injectedCookieCount = yield* Effect.tryPromise(() => pageResult.context.cookies()).pipe( - Effect.map((cookies) => cookies.length), - Effect.catchCause((cause) => - Effect.logDebug("Failed to count cookies", { cause }).pipe(Effect.as(0)), - ), - ); - - return { injectedCookieCount } satisfies OpenResult; - }); - - const snapshot = Effect.fn("McpSession.snapshot")(function* ( - page: Page, - options?: SnapshotOptions, - ) { - return yield* browserService.snapshot(page, options); - }); - - const annotatedScreenshot = Effect.fn("McpSession.annotatedScreenshot")(function* ( - page: Page, - options?: AnnotatedScreenshotOptions, - ) { - return yield* browserService.annotatedScreenshot(page, options); - }); - - const updateLastSnapshot = Effect.fn("McpSession.updateLastSnapshot")(function* ( - snapshotResult: SnapshotResult, - ) { - const sessionData = yield* requireSession(); - sessionData.lastSnapshot = snapshotResult; - }); - - const close = Effect.fn("McpSession.close")(function* () { - const activeSession = yield* Ref.get(sessionRef); - if (!activeSession) return undefined; - - yield* Ref.set(sessionRef, undefined); - - const pollingFiber = yield* Ref.get(pollingFiberRef); - if (pollingFiber) { - yield* Fiber.interrupt(pollingFiber); - yield* Ref.set(pollingFiberRef, undefined); - } - - const liveView = yield* Ref.get(liveViewRef); - if (liveView) { - yield* liveView.close.pipe( - Effect.catchCause((cause) => Effect.logDebug("Failed to close live view", { cause })), - ); - yield* Ref.set(liveViewRef, undefined); - } - - let replaySessionPath: string | undefined; - let reportPath: string | undefined; - let videoPath: string | undefined; - let tmpReplaySessionPath: string | undefined; - let tmpReportPath: string | undefined; - let tmpVideoPath: string | undefined; - const pageVideo = activeSession.page.video(); - const artifactBaseName = activeSession.replayOutputPath - ? path.basename( - activeSession.replayOutputPath, - path.extname(activeSession.replayOutputPath), - ) - : `session-${Date.now()}`; - - yield* Effect.gen(function* () { - if (!activeSession.page.isClosed()) { - const finalEvents = yield* collectAllEvents(activeSession.page).pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to collect final replay events", { cause }).pipe( - Effect.as([] as ReadonlyArray), - ), - ), - ); - if (finalEvents.length > 0) { - activeSession.accumulatedReplayEvents.push(...finalEvents); - } - } - - const resolvedReplayOutputPath = activeSession.replayOutputPath; - if (resolvedReplayOutputPath && activeSession.accumulatedReplayEvents.length > 0) { - const ndjson = - activeSession.accumulatedReplayEvents.map((event) => JSON.stringify(event)).join("\n") + - "\n"; - - yield* fileSystem - .makeDirectory(path.dirname(resolvedReplayOutputPath), { recursive: true }) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to create replay output directory", { cause }), - ), - ); - yield* fileSystem - .writeFileString(resolvedReplayOutputPath, ndjson) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to write replay file", { cause }), - ), - ); - replaySessionPath = resolvedReplayOutputPath; - - const runState = yield* Ref.get(latestRunStateRef); - const replayFileName = path.basename(resolvedReplayOutputPath); - const replayBaseName = path.basename( - resolvedReplayOutputPath, - path.extname(resolvedReplayOutputPath), - ); - const htmlReportPath = path.join( - path.dirname(resolvedReplayOutputPath), - `${replayBaseName}.html`, - ); - const reportHtml = buildReplayViewerHtml({ - title: runState ? `Test Report: ${runState.title}` : "Expect Report", - eventsSource: { ndjsonPath: replayFileName }, - steps: runState, - }); - - yield* fileSystem - .writeFileString(htmlReportPath, reportHtml) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to write HTML report", { cause }), - ), - ); - reportPath = htmlReportPath; - - const ndjsonJsPath = `${resolvedReplayOutputPath}.js`; - const ndjsonJsContent = `window.__EXPECT_REPLAY_NDJSON__ = ${JSON.stringify(ndjson)};\n`; - yield* fileSystem - .writeFileString(ndjsonJsPath, ndjsonJsContent) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to write ndjson.js wrapper", { cause }), - ), - ); - - const tmpReplayPath = path.join( - TMP_ARTIFACT_OUTPUT_DIRECTORY, - `${replayBaseName}.ndjson`, - ); - const tmpReportFilePath = path.join( - TMP_ARTIFACT_OUTPUT_DIRECTORY, - `${replayBaseName}.html`, - ); - const tmpNdjsonJsPath = `${tmpReplayPath}.js`; - - yield* fileSystem - .makeDirectory(TMP_ARTIFACT_OUTPUT_DIRECTORY, { recursive: true }) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to create /tmp artifact directory", { cause }), - ), - ); - yield* fileSystem.copyFile(resolvedReplayOutputPath, tmpReplayPath).pipe( - Effect.tap(() => - Effect.sync(() => { - tmpReplaySessionPath = tmpReplayPath; - }), - ), - Effect.catchCause((cause) => - Effect.logDebug("Failed to copy replay to /tmp", { cause }), - ), - ); - yield* fileSystem.copyFile(htmlReportPath, tmpReportFilePath).pipe( - Effect.tap(() => - Effect.sync(() => { - tmpReportPath = tmpReportFilePath; - }), - ), - Effect.catchCause((cause) => - Effect.logDebug("Failed to copy report to /tmp", { cause }), - ), - ); - yield* fileSystem - .copyFile(ndjsonJsPath, tmpNdjsonJsPath) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to copy ndjson.js to /tmp", { cause }), - ), - ); - - const tmpLatestJsonPath = path.join( - TMP_ARTIFACT_OUTPUT_DIRECTORY, - `${replayBaseName}-latest.json`, - ); - yield* fileSystem - .writeFileString( - tmpLatestJsonPath, - JSON.stringify(activeSession.accumulatedReplayEvents), - ) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to write latest.json to /tmp", { cause }), - ), - ); - - if (runState) { - const tmpStepsJsonPath = path.join( - TMP_ARTIFACT_OUTPUT_DIRECTORY, - `${replayBaseName}-steps.json`, - ); - yield* fileSystem - .writeFileString(tmpStepsJsonPath, JSON.stringify(runState)) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to write steps.json to /tmp", { cause }), - ), - ); - } - } - }).pipe( - Effect.catchCause((cause) => Effect.logDebug("Failed during close cleanup", { cause })), - ); - - yield* Effect.tryPromise(() => activeSession.browser.close()).pipe( - Effect.catchCause((cause) => Effect.logDebug("Failed to close browser", { cause })), - ); - - if (pageVideo) { - videoPath = yield* Effect.tryPromise(() => pageVideo.path()).pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to resolve Playwright video path", { cause }).pipe( - Effect.as(undefined), - ), - ), - ); - - if (videoPath) { - const tmpVideoFilePath = path.join( - TMP_ARTIFACT_OUTPUT_DIRECTORY, - `${artifactBaseName}.webm`, - ); - yield* fileSystem - .makeDirectory(TMP_ARTIFACT_OUTPUT_DIRECTORY, { recursive: true }) - .pipe( - Effect.catchCause((cause) => - Effect.logDebug("Failed to create /tmp artifact directory", { cause }), - ), - ); - yield* fileSystem.copyFile(videoPath, tmpVideoFilePath).pipe( - Effect.tap(() => - Effect.sync(() => { - tmpVideoPath = tmpVideoFilePath; - }), - ), - Effect.catchCause((cause) => - Effect.logDebug("Failed to copy Playwright video to /tmp", { cause }), - ), - ); - } - } - - return { - replaySessionPath, - reportPath, - videoPath, - tmpReplaySessionPath, - tmpReportPath, - tmpVideoPath, - screenshotPaths: yield* Ref.get(savedScreenshotPathsRef), - } satisfies CloseResult; - }); - - return { - open, - navigate, - hasSession: () => Boolean(Ref.getUnsafe(sessionRef)), - getBaseUrl: () => configuredBaseUrl, - requirePage, - requireSession, - snapshot, - annotatedScreenshot, - updateLastSnapshot, - pushStepEvent, - saveScreenshot, - close, - } as const; - }), -}) { - static layer = Layer.effect(this)(this.make).pipe(Layer.provide(Browser.layer)); -} diff --git a/packages/browser/src/mcp/runtime.ts b/packages/browser/src/mcp/runtime.ts deleted file mode 100644 index 1030f48a9..000000000 --- a/packages/browser/src/mcp/runtime.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Layer, Logger, ManagedRuntime } from "effect"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Tracing } from "@expect/shared/observability"; -import { McpSession } from "./mcp-session"; - -const StderrLoggerLayer = Layer.succeed(Logger.LogToStderr, true); - -export const McpRuntime = ManagedRuntime.make( - McpSession.layer.pipe( - Layer.provide(StderrLoggerLayer), - Layer.provide(Tracing.layerAxiom("expect-mcp")), - Layer.provide(NodeServices.layer), - ), -); diff --git a/packages/browser/src/mcp/server.ts b/packages/browser/src/mcp/server.ts deleted file mode 100644 index 28dc96bf4..000000000 --- a/packages/browser/src/mcp/server.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod/v4"; -import { Effect, type ManagedRuntime } from "effect"; -import { evaluateRuntime } from "../utils/evaluate-runtime"; -import { runAccessibilityAudit } from "../accessibility"; -import { formatPerformanceTrace } from "../performance-trace"; -import { McpSession } from "./mcp-session"; -import { autoDiscoverCdp } from "../cdp-discovery"; -import { DUPLICATE_REQUEST_WINDOW_MS } from "./constants"; -import { registerRulesResources } from "./rules-resources"; - -const textResult = (text: string) => ({ - content: [{ type: "text" as const, text }], -}); - -const safeJsonStringify = (data: unknown): string => { - const seen = new WeakSet(); - return JSON.stringify( - data, - (_key, value) => { - if (typeof value === "object" && value !== null) { - if (seen.has(value)) return "[Circular]"; - seen.add(value); - } - return value; - }, - 2, - ); -}; - -const jsonResult = (data: unknown) => textResult(safeJsonStringify(data)); - -const imageResult = (base64: string) => ({ - content: [{ type: "image" as const, data: base64, mimeType: "image/png" }], -}); - -const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; - -// Tool annotations (readOnlyHint, destructiveHint) enable parallel execution in the Claude Agent SDK. -// See: https://platform.claude.com/docs/en/agent-sdk/agent-loop#parallel-tool-execution -export const createBrowserMcpServer = ( - runtime: ManagedRuntime.ManagedRuntime, -) => { - const runMcp = (effect: Effect.Effect) => runtime.runPromise(effect); - - const server = new McpServer({ - name: "expect", - version: "0.0.1", - }); - - server.registerTool( - "open", - { - title: "Open URL", - description: - "Navigate to a URL, launching a browser if needed. Set 'cdp' to a WebSocket URL (e.g. 'ws://localhost:9222/devtools/browser/...') to connect to an already-running Chrome via CDP instead of launching a new browser.", - inputSchema: { - url: z.string().describe("URL to navigate to"), - headed: z.boolean().optional().describe("Show browser window"), - cookies: z - .boolean() - .optional() - .describe("Reuse local browser cookies for the target URL when available"), - waitUntil: z - .enum(["load", "domcontentloaded", "networkidle", "commit"]) - .optional() - .describe("Wait strategy"), - cdp: z - .string() - .optional() - .describe( - "CDP WebSocket endpoint URL to connect to an existing Chrome instance (e.g. 'ws://localhost:9222/devtools/browser/...'). Use 'auto' to auto-discover a running Chrome.", - ), - browser: z - .enum(["chromium", "webkit", "firefox"]) - .optional() - .describe( - "Browser engine to launch (default: chromium). Use 'webkit' for Safari-like testing or 'firefox' for Firefox testing. CDP connections are only supported with chromium.", - ), - }, - }, - ({ url, headed, cookies, waitUntil, cdp, browser: browserType }) => - runMcp( - Effect.gen(function* () { - const session = yield* McpSession; - - if (session.hasSession()) { - yield* session.navigate(url, { waitUntil }); - return textResult(`Navigated to ${url}`); - } - - let cdpUrl: string | undefined; - if (cdp === "auto") { - cdpUrl = yield* autoDiscoverCdp(); - yield* Effect.logInfo("Auto-discovered CDP endpoint", { cdpUrl }); - } else if (cdp) { - cdpUrl = cdp; - } - - const result = yield* session.open(url, { - headed, - cookies, - waitUntil, - cdpUrl, - browserType, - }); - const engineSuffix = browserType && browserType !== "chromium" ? ` [${browserType}]` : ""; - const cdpSuffix = cdpUrl ? ` (connected via CDP: ${cdpUrl})` : ""; - return textResult( - `Opened ${url}${engineSuffix}${cdpSuffix}` + - (result.injectedCookieCount > 0 - ? ` (${result.injectedCookieCount} cookies synced from local browser)` - : ""), - ); - }).pipe(Effect.withSpan(`mcp.tool.open`)), - ), - ); - - server.registerTool( - "playwright", - { - title: "Execute Playwright", - description: - "Execute Playwright code in the Node.js context. Available globals: page (Page), context (BrowserContext), browser (Browser), ref (function: ref ID from snapshot → Playwright Locator). Use `return` to send a value back as JSON. Supports await. Set snapshotAfter=true to automatically take a fresh ARIA snapshot after execution and get updated refs — useful after actions that change the DOM (opening dropdowns, dialogs, navigating).", - inputSchema: { - code: z.string().describe("Playwright code to execute"), - snapshotAfter: z - .boolean() - .optional() - .describe( - "Take a fresh ARIA snapshot after execution and return it alongside the result. Use after actions that change the DOM (dropdowns, dialogs, navigation).", - ), - }, - }, - ({ code, snapshotAfter }) => - runMcp( - Effect.gen(function* () { - const session = yield* McpSession; - const sessionData = yield* session.requireSession(); - - const ref = (refId: string) => { - if (!sessionData.lastSnapshot) - throw new Error("No snapshot taken yet. Call screenshot with mode 'snapshot' first."); - return Effect.runSync(sessionData.lastSnapshot.locator(refId)); - }; - - const codeResult = yield* Effect.promise(async () => { - try { - const userFunction = new AsyncFunction("page", "context", "browser", "ref", code); - const result = await userFunction( - sessionData.page, - sessionData.context, - sessionData.browser, - ref, - ); - return { success: true as const, value: result }; - } catch (error) { - return { - success: false as const, - error: error instanceof Error ? error.message : String(error), - }; - } - }); - - if (!codeResult.success) { - return textResult(`Error: ${codeResult.error}`); - } - - if (snapshotAfter) { - const snapshotResult = yield* session.snapshot(sessionData.page); - yield* session.updateLastSnapshot(snapshotResult); - const resultPayload = - codeResult.value === undefined - ? { - snapshot: { - tree: snapshotResult.tree, - refs: snapshotResult.refs, - stats: snapshotResult.stats, - }, - } - : { - result: codeResult.value, - snapshot: { - tree: snapshotResult.tree, - refs: snapshotResult.refs, - stats: snapshotResult.stats, - }, - }; - return jsonResult(resultPayload); - } - - if (codeResult.value === undefined) return textResult("OK"); - return jsonResult(codeResult.value); - }).pipe(Effect.withSpan(`mcp.tool.playwright`)), - ), - ); - - server.registerTool( - "screenshot", - { - title: "Screenshot", - description: - "Capture the current page state. Modes: 'screenshot' (default, PNG image), 'snapshot' (ARIA accessibility tree with element refs), 'annotated' (screenshot with numbered labels on interactive elements).", - annotations: { readOnlyHint: true }, - inputSchema: { - mode: z - .enum(["screenshot", "snapshot", "annotated"]) - .optional() - .describe("Capture mode (default: screenshot)"), - fullPage: z - .boolean() - .optional() - .describe( - "Capture the full page. For screenshot/annotated: captures full scrollable page. For snapshot: includes all elements in scroll containers instead of only visible ones.", - ), - }, - }, - ({ mode, fullPage }) => - runMcp( - Effect.gen(function* () { - const session = yield* McpSession; - const page = yield* session.requirePage(); - const resolvedMode = mode ?? "screenshot"; - - if (resolvedMode === "snapshot") { - const result = yield* session.snapshot(page, { - viewportAware: !fullPage, - }); - yield* session.updateLastSnapshot(result); - return jsonResult({ tree: result.tree, refs: result.refs, stats: result.stats }); - } - - if (resolvedMode === "annotated") { - const result = yield* session.annotatedScreenshot(page, { fullPage }); - yield* session.saveScreenshot(result.screenshot); - return { - content: [ - { - type: "image" as const, - data: result.screenshot.toString("base64"), - mimeType: "image/png", - }, - { - type: "text" as const, - text: result.annotations - .map( - (annotation) => - `[${annotation.label}] @${annotation.ref} ${annotation.role} "${annotation.name}"`, - ) - .join("\n"), - }, - ], - }; - } - - const buffer = yield* Effect.tryPromise(() => - page.screenshot({ fullPage, scale: "css" }), - ); - yield* session.saveScreenshot(buffer); - return imageResult(buffer.toString("base64")); - }).pipe(Effect.withSpan(`mcp.tool.screenshot`)), - ), - ); - - server.registerTool( - "console_logs", - { - title: "Console Logs", - description: - "Get browser console log messages. Optionally filter by log type (log, warning, error, info, debug).", - annotations: { readOnlyHint: true }, - inputSchema: { - type: z - .string() - .optional() - .describe("Filter by console message type (e.g. 'error', 'warning', 'log')"), - clear: z.boolean().optional().describe("Clear the collected messages after reading"), - }, - }, - ({ type, clear }) => - runMcp( - Effect.gen(function* () { - const session = yield* McpSession; - const sessionData = yield* session.requireSession(); - const entries = type - ? sessionData.consoleMessages.filter((entry) => entry.type === type) - : sessionData.consoleMessages; - if (clear) sessionData.consoleMessages.length = 0; - if (entries.length === 0) return textResult("No console messages captured."); - - const errorCount = entries.filter((entry) => entry.type === "error").length; - const warningCount = entries.filter((entry) => entry.type === "warning").length; - const summary = - errorCount > 0 || warningCount > 0 - ? `${errorCount} error(s), ${warningCount} warning(s) out of ${entries.length} total messages\n\n` - : ""; - - return jsonResult({ summary: summary || undefined, messages: entries }); - }).pipe(Effect.withSpan(`mcp.tool.console_logs`)), - ), - ); - - server.registerTool( - "network_requests", - { - title: "Network Requests", - description: - "Get captured network requests with automatic issue detection. Flags failed requests (4xx/5xx), duplicate requests (same URL+method within 500ms), and mixed content (HTTP on HTTPS pages). Optionally filter by HTTP method, URL substring, or resource type.", - annotations: { readOnlyHint: true }, - inputSchema: { - method: z.string().optional().describe("Filter by HTTP method (e.g. 'GET', 'POST')"), - url: z.string().optional().describe("Filter by URL substring match"), - resourceType: z - .string() - .optional() - .describe("Filter by resource type (e.g. 'xhr', 'fetch', 'document', 'script')"), - clear: z.boolean().optional().describe("Clear the collected requests after reading"), - }, - }, - ({ method, url, resourceType, clear }) => - runMcp( - Effect.gen(function* () { - const session = yield* McpSession; - const sessionData = yield* session.requireSession(); - const normalizedMethod = method?.toUpperCase(); - const normalizedResourceType = resourceType?.toLowerCase(); - const entries = sessionData.networkRequests.filter( - (entry) => - (!normalizedMethod || entry.method === normalizedMethod) && - (!url || entry.url.includes(url)) && - (!normalizedResourceType || entry.resourceType === normalizedResourceType), - ); - if (clear) sessionData.networkRequests.length = 0; - if (entries.length === 0) return textResult("No network requests captured."); - - const failed = entries.filter( - (entry) => entry.status !== undefined && entry.status >= 400, - ); - - const duplicateMap = new Map(); - const lastTimestamp = new Map(); - for (const entry of entries) { - const key = `${entry.method}:${entry.url}`; - const previous = lastTimestamp.get(key); - if ( - previous !== undefined && - Math.abs(entry.timestamp - previous) < DUPLICATE_REQUEST_WINDOW_MS - ) { - const existing = duplicateMap.get(key); - if (existing) { - existing.count++; - } else { - duplicateMap.set(key, { url: entry.url, method: entry.method, count: 2 }); - } - } - lastTimestamp.set(key, entry.timestamp); - } - const duplicates = Array.from(duplicateMap.values()); - - const isHttps = entries.some( - (entry) => entry.resourceType === "document" && entry.url.startsWith("https://"), - ); - const mixedContent = isHttps - ? entries.filter( - (entry) => entry.resourceType !== "document" && entry.url.startsWith("http://"), - ) - : []; - - const issues = { - failedRequests: failed.map((entry) => ({ - url: entry.url, - method: entry.method, - status: entry.status, - })), - duplicateRequests: duplicates, - mixedContent: mixedContent.map((entry) => entry.url), - }; - - const hasIssues = failed.length > 0 || duplicates.length > 0 || mixedContent.length > 0; - - return jsonResult({ - issues: hasIssues ? issues : undefined, - requests: entries, - }); - }).pipe(Effect.withSpan(`mcp.tool.network_requests`)), - ), - ); - - server.registerTool( - "performance_metrics", - { - title: "Performance Metrics", - description: - "Collect a full performance trace: Core Web Vitals (FCP, LCP, CLS, INP), navigation timing (TTFB, server timing), Long Animation Frames (LoAF) with script attribution, and resource breakdown (slowest/largest). Writes the full trace to a file and returns the path plus a summary. Read the file for detailed LoAF script attribution and resource analysis.", - annotations: { readOnlyHint: true }, - inputSchema: {}, - }, - () => - runMcp( - Effect.gen(function* () { - const session = yield* McpSession; - const page = yield* session.requirePage(); - const trace = yield* evaluateRuntime(page, "getPerformanceTrace"); - - const hasMetrics = trace.webVitals.fcp || trace.webVitals.lcp || trace.webVitals.inp; - if (!hasMetrics && trace.longAnimationFrames.length === 0) { - return textResult("No performance metrics available yet."); - } - - const traceDocument = formatPerformanceTrace(trace); - const traceDir = "/tmp/expect-replays"; - const tracePath = path.join(traceDir, `performance-trace-${Date.now()}.md`); - yield* Effect.sync(() => { - mkdirSync(traceDir, { recursive: true }); - writeFileSync(tracePath, traceDocument); - }); - - const summary = [`Performance trace written to: ${tracePath}`, "", "Web Vitals:"]; - const { webVitals } = trace; - if (webVitals.fcp) - summary.push(` FCP: ${webVitals.fcp.value}ms (${webVitals.fcp.rating})`); - if (webVitals.lcp) - summary.push(` LCP: ${webVitals.lcp.value}ms (${webVitals.lcp.rating})`); - if (webVitals.cls) - summary.push(` CLS: ${webVitals.cls.value} (${webVitals.cls.rating})`); - if (webVitals.inp) - summary.push(` INP: ${webVitals.inp.value}ms (${webVitals.inp.rating})`); - if (trace.navigation) { - summary.push(` TTFB: ${trace.navigation.ttfb}ms`); - } - if (trace.longAnimationFrames.length > 0) { - summary.push(`\nLong Animation Frames: ${trace.longAnimationFrames.length} detected`); - const worstBlocking = Math.max( - ...trace.longAnimationFrames.map((frame) => frame.blockingDuration), - ); - summary.push(` Worst blocking duration: ${Math.round(worstBlocking)}ms`); - } - summary.push( - `\nResources: ${trace.resources.totalCount} loaded (${Math.round(trace.resources.totalTransferSizeBytes / 1024)}KB total)`, - ); - - summary.push(`\nFull trace: ${tracePath}`); - - return textResult(summary.join("\n")); - }).pipe(Effect.withSpan(`mcp.tool.performance_metrics`)), - ), - ); - - server.registerTool( - "accessibility_audit", - { - title: "Accessibility Audit", - description: - "Run a WCAG accessibility audit on the current page using two engines (axe-core + IBM Equal Access). Returns violations sorted by severity with CSS selectors, HTML context, WCAG tags, and fix guidance.", - annotations: { readOnlyHint: true }, - inputSchema: { - selector: z - .string() - .optional() - .describe("CSS selector to scope the audit to a specific region of the page"), - tags: z - .array(z.string()) - .optional() - .describe( - "WCAG tags to filter by (default: wcag2a, wcag2aa, wcag21a, wcag21aa). Only applies to the axe-core engine.", - ), - }, - }, - ({ selector, tags }) => - runMcp( - Effect.gen(function* () { - const session = yield* McpSession; - const page = yield* session.requirePage(); - const result = yield* runAccessibilityAudit(page, { selector, tags }); - if (result.violations.length === 0) { - return textResult("No accessibility violations found."); - } - return jsonResult(result); - }).pipe(Effect.withSpan(`mcp.tool.accessibility_audit`)), - ), - ); - - server.registerTool( - "close", - { - title: "Close Browser", - description: "Close the browser and end the session.", - annotations: { destructiveHint: true }, - inputSchema: {}, - }, - () => - runMcp( - Effect.gen(function* () { - const session = yield* McpSession; - const result = yield* session.close(); - if (!result) return textResult("No browser open."); - const lines = ["Browser closed."]; - if (result.tmpReplaySessionPath) { - lines.push(`rrweb replay: ${result.tmpReplaySessionPath}`); - } - if (result.tmpReportPath) { - lines.push(`rrweb report: ${result.tmpReportPath}`); - } - if (result.tmpVideoPath) { - lines.push(`Playwright video: ${result.tmpVideoPath}`); - } else if (result.videoPath) { - lines.push(`Playwright video: ${result.videoPath}`); - } - for (const screenshotPath of result.screenshotPaths) { - lines.push(`Screenshot: ${screenshotPath}`); - } - return textResult(lines.join("\n")); - }).pipe(Effect.withSpan(`mcp.tool.close`)), - ), - ); - - registerRulesResources(server); - - return server; -}; - -export const startBrowserMcpServer = async ( - runtime: ManagedRuntime.ManagedRuntime, -) => { - const server = createBrowserMcpServer(runtime); - const transport = new StdioServerTransport(); - await server.connect(transport); -}; diff --git a/packages/browser/src/mcp/start.ts b/packages/browser/src/mcp/start.ts index c0052ba1a..c9cdfb785 100644 --- a/packages/browser/src/mcp/start.ts +++ b/packages/browser/src/mcp/start.ts @@ -1,29 +1,13 @@ -import { Effect } from "effect"; -import { McpSession } from "./mcp-session"; -import { McpRuntime } from "./runtime"; -import { startBrowserMcpServer } from "./server"; - -let cleanupRegistered = false; - -const closeSession = Effect.gen(function* () { - const session = yield* McpSession; - yield* session.close(); -}); - -const registerProcessCleanup = () => { - if (cleanupRegistered) return; - cleanupRegistered = true; - - const handleShutdown = () => { - void McpRuntime.runPromise(closeSession).finally(() => process.exit(0)); - }; - - process.once("SIGINT", handleShutdown); - process.once("SIGTERM", handleShutdown); - process.once("beforeExit", () => { - void McpRuntime.runPromise(closeSession); - }); -}; - -registerProcessCleanup(); -void startBrowserMcpServer(McpRuntime); +import { Layer, Logger } from "effect"; +import { NodeRuntime, NodeServices } from "@effect/platform-node"; +import { layerMcpServer } from "../mcp-server"; +import { Artifacts } from "../artifacts"; +import { DebugFileLoggerLayer, Tracing } from "@expect/shared/observability"; + +Layer.launch( + layerMcpServer.pipe( + Layer.provide(Artifacts.layer), + Layer.provide(DebugFileLoggerLayer), + Layer.provide(Tracing.layerAxiom("expect-mcp")), + ), +).pipe(NodeRuntime.runMain); diff --git a/packages/browser/src/playwright.ts b/packages/browser/src/playwright.ts new file mode 100644 index 000000000..593fa3e47 --- /dev/null +++ b/packages/browser/src/playwright.ts @@ -0,0 +1,617 @@ +import { Cookies, layerLive } from "@expect/cookies"; +import type { Browser as BrowserProfile, Cookie, ExtractionError } from "@expect/cookies"; +import { chromium, webkit, firefox } from "playwright"; +import type { + Browser as PlaywrightBrowser, + BrowserContext, + ConsoleMessage, + Locator, + Page, + Request, +} from "playwright"; +import { + Array as Arr, + Effect, + Fiber, + FiberHandle, + Layer, + Option, + PlatformError, + Queue, + Result, + Schedule, + Scope, + ServiceMap, + Stream, +} from "effect"; +import { + CONTENT_ROLES, + EVENT_COLLECT_INTERVAL_MS, + HEADLESS_CHROMIUM_ARGS, + INTERACTIVE_ROLES, + NAVIGATION_DETECT_DELAY_MS, + OVERLAY_CONTAINER_ID, + POST_NAVIGATION_SETTLE_MS, + REF_PREFIX, + SNAPSHOT_TIMEOUT_MS, +} from "./constants"; +import { + BrowserAlreadyOpenError, + BrowserLaunchError, + BrowserNotOpenError, + NavigationError, + PlaywrightExecutionError, + SnapshotTimeoutError, +} from "./errors"; +import { type Artifact, ConsoleLog, NetworkRequest, RrwebEvent } from "@expect/shared/models"; +import { Artifacts } from "./artifacts"; +import { toActionError } from "./utils/action-error"; +import { compactTree } from "./utils/compact-tree"; +import { createLocator } from "./utils/create-locator"; +import { evaluateRuntime } from "./utils/evaluate-runtime"; +import { findCursorInteractive } from "./utils/find-cursor-interactive"; +import { getIndentLevel } from "./utils/get-indent-level"; +import { parseAriaLine } from "./utils/parse-aria-line"; +import { resolveNthDuplicates } from "./utils/resolve-nth-duplicates"; +import { computeSnapshotStats } from "./utils/snapshot-stats"; +import { RUNTIME_SCRIPT } from "./generated/runtime-script"; +import type { + AnnotatedScreenshotOptions, + Annotation, + RefMap, + SnapshotOptions, + SnapshotResult, +} from "./types"; + +const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; + +export interface OpenOptions { + readonly headed?: boolean; + readonly cookies?: boolean; + readonly waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit"; + readonly executablePath?: string; +} + +export class PlaywrightSession extends ServiceMap.Service< + PlaywrightSession, + { + readonly browser: PlaywrightBrowser; + readonly context: BrowserContext; + readonly page: Page; + } +>()("@browser/PlaywrightSession") {} + +const withSession = (fn: (session: PlaywrightSession["Service"]) => Promise) => + PlaywrightSession.use((session) => + Effect.tryPromise({ + try: () => fn(session), + catch: (cause) => new BrowserLaunchError({ cause }), + }), + ); + +const shouldAssignRef = (role: string, name: string, interactive?: boolean): boolean => { + if (INTERACTIVE_ROLES.has(role)) return true; + if (interactive) return false; + return CONTENT_ROLES.has(role) && name.length > 0; +}; + +const isSiblingProfile = (profile: BrowserProfile, reference: BrowserProfile) => { + if (profile._tag !== reference._tag) return false; + if (profile._tag === "ChromiumBrowser" && reference._tag === "ChromiumBrowser") { + return profile.key === reference.key && profile.profilePath !== reference.profilePath; + } + if (profile._tag === "FirefoxBrowser" && reference._tag === "FirefoxBrowser") { + return profile.profilePath !== reference.profilePath; + } + return false; +}; + +const appendCursorInteractiveElements = Effect.fn("Playwright.appendCursorInteractive")(function* ( + page: Page, + filteredLines: string[], + refs: RefMap, + refCount: number, + options: SnapshotOptions, +) { + const cursorElements = yield* findCursorInteractive(page, options.selector); + if (cursorElements.length === 0) return refCount; + + const existingNames = new Set(Object.values(refs).map((entry) => entry.name.toLowerCase())); + const newLines: string[] = []; + + for (const element of cursorElements) { + if (existingNames.has(element.text.toLowerCase())) continue; + existingNames.add(element.text.toLowerCase()); + + const ref = `${REF_PREFIX}${++refCount}`; + refs[ref] = { + role: "clickable", + name: element.text, + selector: element.selector, + }; + newLines.push(`- clickable "${element.text}" [ref=${ref}] [${element.reason}]`); + } + + if (newLines.length > 0) { + filteredLines.push("# Cursor-interactive elements:"); + filteredLines.push(...newLines); + } + + return refCount; +}); + +const injectOverlayLabels = (page: Page, labels: Array<{ label: number; x: number; y: number }>) => + evaluateRuntime(page, "injectOverlayLabels", OVERLAY_CONTAINER_ID, labels); + +export interface CreateSessionOptions { + headless: boolean; + browserOverride?: "chromium" | "webkit" | "firefox"; + cdpUrl: Option.Option; + /** @note(rasmus): optional profile to use */ + browserProfile: Option.Option; + initialNavigation: Option.Option<{ + url: string; + waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit" | undefined; + }>; +} + +export class Playwright extends ServiceMap.Service()("@browser/Playwright", { + make: Effect.gen(function* () { + const artifacts = yield* Artifacts; + const cookies = yield* Cookies; + + let session: PlaywrightSession["Service"] | undefined; + + const handle = yield* FiberHandle.make(); + + const withCurrentSession = ( + effect: Effect.Effect, + ) => + Effect.gen(function* () { + if (session === undefined) return yield* new BrowserNotOpenError(); + return yield* effect.pipe(Effect.provideService(PlaywrightSession, session)); + }); + + const withCreateSession = + ({ + headless, + browserProfile, + initialNavigation, + cdpUrl, + browserOverride, + }: CreateSessionOptions) => + ( + effect: Effect.Effect, + ): Effect.Effect< + A, + E | BrowserLaunchError | ExtractionError | NavigationError | PlatformError.PlatformError, + Exclude | Scope.Scope + > => + Effect.gen(function* () { + if (session !== undefined) { + return yield* effect.pipe(Effect.provideService(PlaywrightSession, session)); + } + + const engine = browserOverride ?? "chromium"; + const browser = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => { + if (cdpUrl._tag === "Some") return chromium.connectOverCDP(cdpUrl.value); + if (engine === "webkit") return webkit.launch({ headless }); + if (engine === "firefox") return firefox.launch({ headless }); + return chromium.launch({ + headless, + args: headless ? HEADLESS_CHROMIUM_ARGS : [], + }); + }, + catch: (cause) => new BrowserLaunchError({ cause }), + }), + (browser) => + Effect.tryPromise(() => browser.close()).pipe( + Effect.ignore({ + message: "Failed to close browser process", + log: "Warn", + }), + ), + ); + + const contextOptions: Parameters[0] = + browserProfile._tag === "Some" && browserProfile.value._tag === "ChromiumBrowser" + ? { locale: browserProfile.value.locale } + : {}; + + const context = yield* Effect.tryPromise({ + try: () => browser.newContext(contextOptions), + catch: (cause) => new BrowserLaunchError({ cause }), + }); + yield* Effect.tryPromise({ + try: () => context.addInitScript(RUNTIME_SCRIPT), + catch: (cause) => new BrowserLaunchError({ cause }), + }); + + /** cookies */ + if (Option.isSome(browserProfile)) { + const extractedCookies = yield* cookies.extract(browserProfile.value); + yield* Effect.tryPromise({ + try: () => + context.addCookies(extractedCookies.map((cookie) => cookie.playwrightFormat)), + catch: (cause) => new BrowserLaunchError({ cause }), + }); + } + + yield* Effect.tryPromise({ + try: () => context.addInitScript(RUNTIME_SCRIPT), + catch: (cause) => new BrowserLaunchError({ cause }), + }); + + const page = yield* Effect.tryPromise({ + try: () => context.newPage(), + catch: (cause) => new BrowserLaunchError({ cause }), + }); + + if (Option.isSome(initialNavigation)) { + yield* Effect.tryPromise({ + try: () => + page.goto(initialNavigation.value.url, { + waitUntil: initialNavigation.value.waitUntil ?? "load", + }), + catch: (cause) => + new NavigationError({ + url: initialNavigation.value.url, + cause, + }), + }); + } + + session = { browser, context, page }; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + session = undefined; + }), + ); + + return yield* effect.pipe(Effect.provideService(PlaywrightSession, session)); + }); + + // The entire browser session as a single scoped effect. + // Launched via FiberHandle — interrupting the handle triggers the finalizer + // which collects final rrweb events and closes the browser. + const runSession = Effect.fn("Playwright.runSession")( + function* (options: CreateSessionOptions) { + const { page } = yield* PlaywrightSession; + yield* Effect.addFinalizer(() => Effect.logDebug(`Browser being cleaned up`)); + + // Page event stream — console logs and network requests from Playwright callbacks + const pageEvents = Stream.callback((queue) => + Effect.gen(function* () { + const onConsole = (message: ConsoleMessage) => { + Queue.offerUnsafe( + queue, + new ConsoleLog({ + type: message.type(), + text: message.text(), + timestamp: Date.now(), + }), + ); + }; + + const onRequest = (request: Request) => { + Queue.offerUnsafe( + queue, + new NetworkRequest({ + url: request.url(), + method: request.method(), + status: undefined, + resourceType: request.resourceType(), + timestamp: Date.now(), + }), + ); + }; + + page.on("console", onConsole); + page.on("request", onRequest); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + page.off("console", onConsole); + page.off("request", onRequest); + }), + ); + }), + ); + + // Start rrweb recording + yield* evaluateRuntime(page, "startRecording"); + yield* Effect.addFinalizer(() => + evaluateRuntime(page, "stopRecording").pipe( + Effect.ignore({ + log: "Warn", + message: `Rrweb recording stopping failed`, + }), + ), + ); + + // rrweb polling stream — drains buffered events from the page runtime. + // Survives navigation — evaluate can fail transiently when the execution + // context is destroyed mid-navigation, so errors return an empty batch. + const pollOnce = Effect.gen(function* () { + if (page.isClosed()) + return yield* new PlaywrightExecutionError({ + cause: "Page is closed!", + }); + const recording = yield* evaluateRuntime(page, "isRecording"); + if (!recording) { + yield* Effect.logInfo("rrweb recording lost after navigation, restarting"); + yield* evaluateRuntime(page, "startRecording"); + } + const events = yield* evaluateRuntime(page, "getEvents"); + + /** + * @note(rasmus): we do this because we're too lazy to properly make schemas for Rrweb events, so + * undefined values don't get properly serialized when they cross the RPC boundary + */ + return events.map( + (event) => new RrwebEvent({ event: JSON.parse(JSON.stringify(event)) }), + ); + }).pipe( + Effect.tapCause((cause) => Effect.logError(`Gathering Rrweb events failed`, cause)), + Effect.catch(() => Effect.succeed([] as RrwebEvent[])), + ); + + const rrwebEvents = Stream.fromEffectSchedule( + pollOnce, + Schedule.spaced(EVENT_COLLECT_INTERVAL_MS), + ).pipe(Stream.flatMap((batch) => Stream.fromIterable(batch))); + + // Merge both streams and push all artifacts until interrupted + // yield* Stream.merge(rrwebEvents, pageEvents).pipe( + yield* rrwebEvents.pipe( + Stream.chunks, + Stream.tap((artifactBatch) => artifacts.push(artifactBatch)), + Stream.runDrain, + ); + }, + (effect, options) => withCreateSession(options)(effect), + Effect.tapCause((cause) => Effect.logError(`Running session failed`, cause)), + Effect.annotateLogs({ fiber: "runSession" }), + Effect.scoped, + ); + + const open = Effect.fn("Playwright.open")(function* (options: CreateSessionOptions) { + if (session) return yield* new BrowserAlreadyOpenError(); + yield* runSession(options).pipe(FiberHandle.run(handle)); + return yield* Effect.suspend(() => + session ? Effect.succeed(session) : Effect.fail(new BrowserNotOpenError()), + ).pipe( + Effect.retry({ schedule: Schedule.spaced("100 millis") }), + Effect.timeout("15 seconds"), + Effect.catchTag("TimeoutError", () => + FiberHandle.clear(handle).pipe( + Effect.flatMap(() => Effect.fail(new BrowserNotOpenError())), + ), + ), + ); + }); + + const close = Effect.fn("Playwright.close")(function* () { + session = undefined; + yield* FiberHandle.clear(handle); + }, Effect.scoped); + + const navigate = Effect.fn("Playwright.navigate")(function* ( + url: string, + options: { + waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit"; + } = {}, + ) { + const { page } = yield* PlaywrightSession; + yield* Effect.tryPromise({ + try: () => page.goto(url, { waitUntil: options.waitUntil ?? "load" }), + catch: (cause) => + new NavigationError({ + url, + cause: cause instanceof Error ? cause.message : String(cause), + }), + }); + }, withCurrentSession); + + /* + + export const scoped = (self: Effect.Effect): Effect.Effect> => + withFiber((fiber) => { + const prev = fiber.services + const scope = scopeMakeUnsafe() + fiber.setServices(ServiceMap.add(fiber.services, scopeTag, scope)) + return onExitPrimitive(self, (exit) => { + fiber.setServices(prev) + return scopeCloseUnsafe(scope, exit) + }) + }) as any + + + export const scoped: ( + self: Effect + ) => Effect> = internal.scoped + */ + + const snapshot = Effect.fn("Playwright.snapshot")(function* (options: SnapshotOptions) { + const { page } = yield* PlaywrightSession; + const timeout = options.timeout ?? SNAPSHOT_TIMEOUT_MS; + const selector = options.selector ?? "body"; + yield* Effect.annotateCurrentSpan({ selector }); + + const rawTree = yield* Effect.tryPromise({ + try: () => page.locator(selector).ariaSnapshot({ timeout }), + catch: (cause) => + new SnapshotTimeoutError({ + selector, + timeoutMs: timeout, + cause: cause instanceof Error ? cause.message : String(cause), + }), + }); + + const refs: RefMap = {}; + const filteredLines: string[] = []; + let refCount = 0; + + for (const line of rawTree.split("\n")) { + if (options.maxDepth !== undefined && getIndentLevel(line) > options.maxDepth) continue; + + const parsed = parseAriaLine(line); + if (Option.isNone(parsed)) { + if (!options.interactive) filteredLines.push(line); + continue; + } + + const { role, name } = parsed.value; + if (options.interactive && !INTERACTIVE_ROLES.has(role)) continue; + + if (shouldAssignRef(role, name, options.interactive)) { + const ref = `${REF_PREFIX}${++refCount}`; + refs[ref] = { role, name }; + filteredLines.push(`${line} [ref=${ref}]`); + } else { + filteredLines.push(line); + } + } + + if (options.cursor) { + refCount = yield* appendCursorInteractiveElements( + page, + filteredLines, + refs, + refCount, + options, + ); + } + + resolveNthDuplicates(refs); + + let tree = filteredLines.join("\n"); + if (options.interactive && refCount === 0) tree = "(no interactive elements)"; + if (options.compact) tree = compactTree(tree); + + const stats = computeSnapshotStats(tree, refs); + + return { + tree, + refs, + stats, + locator: createLocator(page, refs), + } satisfies SnapshotResult; + }, withCurrentSession); + + const act = Effect.fn("Playwright.act")(function* ( + ref: string, + action: (locator: Locator) => Promise, + options?: SnapshotOptions, + ) { + yield* Effect.annotateCurrentSpan({ ref }); + const before = yield* snapshot(options ?? {}); + const locator = yield* before.locator(ref); + yield* Effect.tryPromise({ + try: () => action(locator), + catch: (error) => toActionError(error, ref), + }); + return yield* snapshot(options ?? {}); + }, withCurrentSession); + + const annotatedScreenshot = Effect.fn("Playwright.annotatedScreenshot")(function* ( + options: AnnotatedScreenshotOptions, + ) { + const { page } = yield* PlaywrightSession; + const snapshotResult = yield* snapshot(options); + const annotations: Annotation[] = []; + const labelPositions: Array<{ label: number; x: number; y: number }> = []; + + let labelCounter = 0; + + for (const [ref, entry] of Object.entries(snapshotResult.refs)) { + const locator = yield* snapshotResult.locator(ref); + const box = yield* Effect.tryPromise(() => locator.boundingBox()).pipe( + Effect.catchTag("UnknownError", () => Effect.succeed(undefined)), + ); + if (!box) continue; + + labelCounter++; + annotations.push({ + label: labelCounter, + ref, + role: entry.role, + name: entry.name, + }); + labelPositions.push({ label: labelCounter, x: box.x, y: box.y }); + } + + yield* injectOverlayLabels(page, labelPositions); + return yield* Effect.ensuring( + withSession(({ page }) => page.screenshot({ fullPage: options.fullPage })).pipe( + Effect.map((screenshotBuffer) => ({ + screenshot: screenshotBuffer, + annotations, + })), + ), + // HACK: overlay removal is best-effort — evaluateRuntime uses Effect.promise which defects on failure + evaluateRuntime(page, "removeOverlay", OVERLAY_CONTAINER_ID).pipe( + Effect.catchCause(() => Effect.void), + ), + ); + }, withCurrentSession); + + const waitForNavigationSettle = Effect.fn("Playwright.waitForNavigationSettle")(function* ( + urlBefore: string, + ) { + const { page } = yield* PlaywrightSession; + yield* withSession(({ page }) => + page.waitForURL((url) => url.toString() !== urlBefore, { + timeout: NAVIGATION_DETECT_DELAY_MS, + waitUntil: "commit", + }), + ).pipe(Effect.catchTag("BrowserLaunchError", () => Effect.void)); + if (page.url() !== urlBefore) { + yield* Effect.tryPromise(() => page.waitForLoadState("domcontentloaded")).pipe( + Effect.catchTag("UnknownError", () => Effect.void), + ); + yield* withSession(({ page }) => page.waitForTimeout(POST_NAVIGATION_SETTLE_MS)); + } + }, withCurrentSession); + + const execute = Effect.fn("Playwright.execute")(function* ( + code: string, + snapshot: SnapshotResult, + ) { + const { page } = yield* PlaywrightSession; + const ref = (refId: string) => Effect.runSync(snapshot.locator(refId)); + return yield* Effect.tryPromise({ + try: async () => { + const userFunction = new AsyncFunction("page", "context", "browser", "ref", code); + return await userFunction(page, page.context(), page.context().browser(), ref); + }, + catch: (cause) => new PlaywrightExecutionError({ cause }), + }); + }, withCurrentSession); + + const getPage = PlaywrightSession.use(({ page }) => Effect.succeed(page)).pipe( + withCurrentSession, + ); + + return { + open, + close, + navigate, + snapshot, + act, + annotatedScreenshot, + waitForNavigationSettle, + hasSession: () => Boolean(session), + withCurrentSession, + getPage, + execute, + } as const; + }), +}) { + static layer = Layer.effect(this)(this.make).pipe( + Layer.provide(Cookies.layer), + Layer.provide(layerLive), + ); +} diff --git a/packages/browser/src/recorder.ts b/packages/browser/src/recorder.ts deleted file mode 100644 index 9aa196075..000000000 --- a/packages/browser/src/recorder.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Page } from "playwright"; -import type { eventWithTime } from "@rrweb/types"; -import { Effect, Predicate } from "effect"; -import { FileSystem } from "effect/FileSystem"; -import { evaluateRuntime } from "./utils/evaluate-runtime"; -import { RecorderInjectionError, SessionLoadError } from "./errors"; -import type { CollectResult } from "./types"; - -export const collectEvents = Effect.fn("Recorder.collectEvents")(function* (page: Page) { - const events = yield* evaluateRuntime(page, "getEvents").pipe( - Effect.catchCause((cause) => new RecorderInjectionError({ cause: String(cause) }).asEffect()), - ); - const total = yield* evaluateRuntime(page, "getEventCount").pipe( - Effect.catchCause((cause) => new RecorderInjectionError({ cause: String(cause) }).asEffect()), - ); - - return { events, total: total + events.length } satisfies CollectResult; -}); - -export const collectAllEvents = Effect.fn("Recorder.collectAllEvents")(function* (page: Page) { - return yield* evaluateRuntime(page, "getAllEvents").pipe( - Effect.catchCause((cause) => new RecorderInjectionError({ cause: String(cause) }).asEffect()), - ); -}); - -const isRrwebEvent = (value: unknown): value is eventWithTime => - Predicate.isObject(value) && "type" in value && "timestamp" in value; - -export const loadSession = Effect.fn("Recorder.loadSession")(function* (sessionPath: string) { - const fileSystem = yield* FileSystem; - const content = yield* fileSystem - .readFileString(sessionPath) - .pipe( - Effect.catchTag("PlatformError", (error) => - new SessionLoadError({ path: sessionPath, cause: String(error) }).asEffect(), - ), - ); - - const lines = content.trim().split("\n"); - const events = yield* Effect.forEach(lines, (line, index) => - Effect.try({ - try: () => { - const parsed: unknown = JSON.parse(line); - if (!isRrwebEvent(parsed)) { - throw new Error("Missing required 'type' and 'timestamp' fields"); - } - return parsed; - }, - catch: (cause) => - new SessionLoadError({ - path: sessionPath, - cause: `Invalid rrweb event at line ${index + 1}: ${String(cause)}`, - }), - }), - ); - - return events; -}); diff --git a/packages/browser/src/replay-viewer.ts b/packages/browser/src/replay-viewer.ts deleted file mode 100644 index dc165490e..000000000 --- a/packages/browser/src/replay-viewer.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { REPLAY_PLAYER_HEIGHT_PX, REPLAY_PLAYER_WIDTH_PX } from "./constants"; -import type { ViewerRunState } from "./mcp/viewer-events"; - -const escapeHtml = (text: string): string => - text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - -const RRWEB_PLAYER_VERSION = "2.0.0-alpha.18"; -const RRWEB_PLAYER_CSS = `https://cdn.jsdelivr.net/npm/rrweb-player@${RRWEB_PLAYER_VERSION}/dist/style.css`; -const RRWEB_PLAYER_JS = `https://cdn.jsdelivr.net/npm/rrweb-player@${RRWEB_PLAYER_VERSION}/dist/rrweb-player.js`; - -type EventsSource = "sse" | { ndjsonPath: string }; - -interface ReplayViewerOptions { - title: string; - bodyHtml?: string; - eventsSource?: EventsSource; - steps?: ViewerRunState; -} - -const buildLoadEventsScript = (source: EventsSource): string => { - if (source === "sse") { - return ` - const res = await fetch('/latest.json'); - if (res.ok) { allEvents = await res.json(); if (allEvents.length >= 2) initPlayer(allEvents); } - const es = new EventSource('/events'); - es.addEventListener('replay', (msg) => { try { for (const e of JSON.parse(msg.data)) { allEvents.push(e); if (player) player.getReplayer().addEvent(e); } if (!player && allEvents.length >= 2) initPlayer(allEvents); } catch {} }); - es.addEventListener('steps', (msg) => { try { updateSteps(JSON.parse(msg.data)); } catch {} }); - es.onerror = () => { if (statusEl) statusEl.textContent = 'Connection lost. Retrying...'; };`; - } - - const escapedPath = source.ndjsonPath.replaceAll("'", "\\'"); - return ` - if (window.__EXPECT_REPLAY_NDJSON__) { - allEvents = window.__EXPECT_REPLAY_NDJSON__.trim().split('\\n').map(l => JSON.parse(l)); - if (allEvents.length >= 2) initPlayer(allEvents); - else if (statusEl) statusEl.textContent = 'No replay events recorded.'; - } else { - const res = await fetch('${escapedPath}'); - if (res.ok) { - allEvents = (await res.text()).trim().split('\\n').map(l => JSON.parse(l)); - if (allEvents.length >= 2) initPlayer(allEvents); - else if (statusEl) statusEl.textContent = 'No replay events recorded.'; - } else if (statusEl) statusEl.textContent = 'Failed to load replay.'; - }`; -}; - -const buildStepsScript = (steps?: ViewerRunState): string => { - const initialData = steps ? JSON.stringify(steps) : "null"; - return ` - const stepsPanel = document.getElementById('steps-panel'); - const runTitle = document.getElementById('run-title'); - const runStatus = document.getElementById('run-status'); - const runSummary = document.getElementById('run-summary'); - const stepsList = document.getElementById('steps-list'); - let initialSteps = ${initialData}; - if (initialSteps) updateSteps(initialSteps); - - function updateSteps(state) { - if (!stepsPanel || !state) return; - stepsPanel.style.display = 'block'; - if (runTitle) runTitle.textContent = state.title || 'Test Run'; - if (runStatus) { - runStatus.textContent = state.status; - runStatus.className = 'run-status status-' + state.status; - } - if (runSummary) { - runSummary.textContent = state.summary || ''; - runSummary.style.display = state.summary ? 'block' : 'none'; - } - if (stepsList && state.steps) { - stepsList.innerHTML = ''; - for (const step of state.steps) { - const li = document.createElement('li'); - li.className = 'step-item step-' + step.status; - const badge = document.createElement('span'); - badge.className = 'step-badge'; - badge.textContent = step.status === 'passed' ? '\\u2713' - : step.status === 'failed' ? '\\u2717' - : step.status === 'active' ? '\\u25CF' - : '\\u25CB'; - const title = document.createElement('span'); - title.className = 'step-title'; - title.textContent = step.title; - li.appendChild(badge); - li.appendChild(title); - if (step.summary) { - const summary = document.createElement('span'); - summary.className = 'step-summary'; - summary.textContent = step.summary; - li.appendChild(summary); - } - stepsList.appendChild(li); - } - } - }`; -}; - -const stepsStyles = ` - #steps-panel { display: none; margin-bottom: 24px; background: #1e293b; border-radius: 8px; padding: 20px; border: 1px solid #334155 } - #steps-panel h2 { margin: 0 0 4px 0; font-size: 16px; font-weight: 600 } - .run-status { display: inline-block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 4px; margin-bottom: 12px } - .status-running { background: #1e3a5f; color: #60a5fa } - .status-passed { background: #14532d; color: #4ade80 } - .status-failed { background: #7f1d1d; color: #f87171 } - #run-summary { font-size: 13px; color: #94a3b8; margin-bottom: 16px } - #steps-list { list-style: none; padding: 0; margin: 0 } - .step-item { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; font-size: 14px; border-top: 1px solid #1e293b } - .step-item:first-child { border-top: none } - .step-badge { flex-shrink: 0; width: 16px; text-align: center; font-size: 13px } - .step-pending .step-badge { color: #64748b } - .step-active .step-badge { color: #60a5fa; animation: pulse 1.5s infinite } - .step-passed .step-badge { color: #4ade80 } - .step-failed .step-badge { color: #f87171 } - .step-title { color: #e2e8f0 } - .step-summary { color: #94a3b8; font-size: 12px; margin-left: auto; max-width: 50%; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap } - @keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }`; - -export const buildReplayViewerHtml = (options: ReplayViewerOptions): string => { - const source = options.eventsSource; - const isLive = source === "sse"; - - const ndjsonLoaderScript = - source !== undefined && source !== "sse" - ? ` - ` - : ""; - - const replaySection = - source !== undefined - ? ` -
Loading replay…
- ${ndjsonLoaderScript} - ` - : ""; - - const stepsSection = ` -
-

Test Run

-
running
- -
    -
    - `; - - return ` - - - - - ${escapeHtml(options.title)} - ${source !== undefined ? `` : ""} - - - -
    - ${options.bodyHtml ?? ""} - ${stepsSection} - ${replaySection} -
    - -`; -}; diff --git a/packages/browser/src/rrvideo.ts b/packages/browser/src/rrvideo.ts index 1c70a15d7..55cf9c6df 100644 --- a/packages/browser/src/rrvideo.ts +++ b/packages/browser/src/rrvideo.ts @@ -17,24 +17,26 @@ const resolveRrwebAssets = Effect.fn("RrVideo.resolveRrwebAssets")(function* () const rrwebEntry = yield* Effect.try({ try: () => require.resolve("rrweb"), catch: (cause) => - new RrVideoConvertError({ cause: `Failed to resolve rrweb package: ${String(cause)}` }), + new RrVideoConvertError({ + cause: `Failed to resolve rrweb package: ${String(cause)}`, + }), }); const rrwebUmdPath = path.resolve(rrwebEntry, "../../dist/rrweb.umd.cjs"); const rrwebStylePath = path.resolve(rrwebEntry, "../../dist/style.css"); - const rrwebScript = yield* fileSystem - .readFileString(rrwebUmdPath) - .pipe( - Effect.catchTag("PlatformError", (cause) => - new RrVideoConvertError({ cause: `Failed to read rrweb script: ${cause}` }).asEffect(), - ), - ); - const rrwebStyle = yield* fileSystem - .readFileString(rrwebStylePath) - .pipe( - Effect.catchTag("PlatformError", (cause) => - new RrVideoConvertError({ cause: `Failed to read rrweb style: ${cause}` }).asEffect(), - ), - ); + const rrwebScript = yield* fileSystem.readFileString(rrwebUmdPath).pipe( + Effect.catchTag("PlatformError", (cause) => + new RrVideoConvertError({ + cause: `Failed to read rrweb script: ${cause}`, + }).asEffect(), + ), + ); + const rrwebStyle = yield* fileSystem.readFileString(rrwebStylePath).pipe( + Effect.catchTag("PlatformError", (cause) => + new RrVideoConvertError({ + cause: `Failed to read rrweb style: ${cause}`, + }).asEffect(), + ), + ); return { rrwebScript, rrwebStyle }; }); @@ -139,7 +141,10 @@ interface ReplayOptions { readonly scaledViewport: { readonly width: number; readonly height: number }; readonly totalTimeout: number; readonly tempVideoDir: string; - readonly replayConfig: { readonly speed?: number; readonly skipInactive?: boolean }; + readonly replayConfig: { + readonly speed?: number; + readonly skipInactive?: boolean; + }; readonly rrwebAssets: RrwebAssets; readonly onProgress?: (percent: number) => void; } @@ -158,17 +163,25 @@ const replayToVideo = Effect.fn("RrVideo.replayToVideo")(function* ( try: () => browser.newContext({ viewport: options.scaledViewport, - recordVideo: { dir: options.tempVideoDir, size: options.scaledViewport }, + recordVideo: { + dir: options.tempVideoDir, + size: options.scaledViewport, + }, }), catch: (cause) => - new RrVideoConvertError({ cause: `Failed to create browser context: ${String(cause)}` }), + new RrVideoConvertError({ + cause: `Failed to create browser context: ${String(cause)}`, + }), }), closeContext, ); const page = yield* Effect.tryPromise({ try: () => context.newPage(), - catch: (cause) => new RrVideoConvertError({ cause: `Failed to create page: ${String(cause)}` }), + catch: (cause) => + new RrVideoConvertError({ + cause: `Failed to create page: ${String(cause)}`, + }), }); yield* Effect.tryPromise({ @@ -178,7 +191,10 @@ const replayToVideo = Effect.fn("RrVideo.replayToVideo")(function* ( options.onProgress?.(progress); }); }, - catch: (cause) => new RrVideoConvertError({ cause: `Failed to set up page: ${String(cause)}` }), + catch: (cause) => + new RrVideoConvertError({ + cause: `Failed to set up page: ${String(cause)}`, + }), }); yield* Effect.tryPromise({ @@ -210,17 +226,23 @@ const replayToVideo = Effect.fn("RrVideo.replayToVideo")(function* ( }); }), catch: (cause) => - new RrVideoConvertError({ cause: `Replay execution failed: ${String(cause)}` }), + new RrVideoConvertError({ + cause: `Replay execution failed: ${String(cause)}`, + }), }); const videoPath = yield* Effect.tryPromise({ try: async () => (await page.video()?.path()) ?? "", catch: (cause) => - new RrVideoConvertError({ cause: `Failed to get video path: ${String(cause)}` }), + new RrVideoConvertError({ + cause: `Failed to get video path: ${String(cause)}`, + }), }); if (!videoPath) { - return yield* new RrVideoConvertError({ cause: "No video file produced by Playwright" }); + return yield* new RrVideoConvertError({ + cause: "No video file produced by Playwright", + }); } return videoPath; @@ -235,7 +257,9 @@ export class RrVideo extends ServiceMap.Service()("@expect/browser/RrVi Effect.tryPromise({ try: () => chromium.launch({ headless: true }), catch: (cause) => - new RrVideoConvertError({ cause: `Failed to launch browser: ${String(cause)}` }), + new RrVideoConvertError({ + cause: `Failed to launch browser: ${String(cause)}`, + }), }), (browser) => Effect.promise(() => browser.close()).pipe( @@ -243,28 +267,106 @@ export class RrVideo extends ServiceMap.Service()("@expect/browser/RrVi ), ); + const convertEvents = Effect.fn("RrVideo.convertEvents")(function* ( + options: Omit & { events: RrwebEvent[] }, + ) { + const { events } = options; + const ratio = Math.min(options.resolutionRatio ?? DEFAULT_RESOLUTION_RATIO, 1); + const scaleFactor = ratio * MAX_SCALE_VALUE; + if (events.length === 0) { + return yield* new RrVideoConvertError({ + cause: "No events in session file", + }); + } + + const maxViewport = getMaxViewport(events); + if (maxViewport.width === 0 || maxViewport.height === 0) { + return yield* new RrVideoConvertError({ + cause: "Could not determine viewport size from events", + }); + } + + const scaledViewport = { + width: Math.round(maxViewport.width * scaleFactor), + height: Math.round(maxViewport.height * scaleFactor), + }; + + const videoDuration = events[events.length - 1].timestamp - events[0].timestamp; + const playbackSpeed = options.speed ?? 1; + const expectedPlaybackTime = videoDuration / playbackSpeed; + const totalTimeout = expectedPlaybackTime + TIMEOUT_BUFFER_MS; + + const tempVideoDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "rrvideo-" }).pipe( + Effect.catchTag("PlatformError", (cause) => + new RrVideoConvertError({ + cause: `Failed to create temp dir: ${cause}`, + }).asEffect(), + ), + ); + + const browser = yield* acquireBrowser; + + const tempVideoPath = yield* replayToVideo(browser, { + eventsJson: JSON.stringify(events), + scaleFactor, + scaledViewport, + totalTimeout, + tempVideoDir, + replayConfig: { + speed: options.speed, + skipInactive: options.skipInactive, + }, + rrwebAssets, + onProgress: options.onProgress, + }); + + yield* fileSystem + .makeDirectory(path.dirname(options.outputPath), { recursive: true }) + .pipe(Effect.catchReason("PlatformError", "AlreadyExists", () => Effect.void)); + + yield* fileSystem.copyFile(tempVideoPath, options.outputPath).pipe( + Effect.catchTag("PlatformError", (cause) => + new RrVideoConvertError({ + cause: `Failed to copy video: ${cause}`, + }).asEffect(), + ), + ); + + yield* Effect.logInfo("rrweb session converted to video", { + outputPath: options.outputPath, + videoDuration, + playbackSpeed, + }); + + return options.outputPath; + }, Effect.scoped); + const convert = Effect.fn("RrVideo.convert")(function* (options: ConvertOptions) { yield* Effect.annotateCurrentSpan({ inputPath: options.inputPath }); const ratio = Math.min(options.resolutionRatio ?? DEFAULT_RESOLUTION_RATIO, 1); const scaleFactor = ratio * MAX_SCALE_VALUE; - const eventsJson = yield* fileSystem - .readFileString(options.inputPath) - .pipe( - Effect.catchTag("PlatformError", (cause) => - new RrVideoConvertError({ cause: `Failed to read input: ${cause}` }).asEffect(), - ), - ); + const eventsJson = yield* fileSystem.readFileString(options.inputPath).pipe( + Effect.catchTag("PlatformError", (cause) => + new RrVideoConvertError({ + cause: `Failed to read input: ${cause}`, + }).asEffect(), + ), + ); const parsed = yield* Effect.try({ try: () => JSON.parse(eventsJson) as unknown, catch: (cause) => - new RrVideoConvertError({ cause: `Failed to parse events: ${String(cause)}` }), + new RrVideoConvertError({ + cause: `Failed to parse events: ${String(cause)}`, + }), }); if (!Array.isArray(parsed)) { - return yield* new RrVideoConvertError({ cause: "Events file must contain a JSON array" }); + return yield* new RrVideoConvertError({ + cause: "Events file must contain a JSON array", + }); } const events: RrwebEvent[] = []; @@ -278,7 +380,9 @@ export class RrVideo extends ServiceMap.Service()("@expect/browser/RrVi } if (events.length === 0) { - return yield* new RrVideoConvertError({ cause: "No events in session file" }); + return yield* new RrVideoConvertError({ + cause: "No events in session file", + }); } const maxViewport = getMaxViewport(events); @@ -298,13 +402,13 @@ export class RrVideo extends ServiceMap.Service()("@expect/browser/RrVi const expectedPlaybackTime = videoDuration / playbackSpeed; const totalTimeout = expectedPlaybackTime + TIMEOUT_BUFFER_MS; - const tempVideoDir = yield* fileSystem - .makeTempDirectoryScoped({ prefix: "rrvideo-" }) - .pipe( - Effect.catchTag("PlatformError", (cause) => - new RrVideoConvertError({ cause: `Failed to create temp dir: ${cause}` }).asEffect(), - ), - ); + const tempVideoDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "rrvideo-" }).pipe( + Effect.catchTag("PlatformError", (cause) => + new RrVideoConvertError({ + cause: `Failed to create temp dir: ${cause}`, + }).asEffect(), + ), + ); const browser = yield* acquireBrowser; @@ -314,7 +418,10 @@ export class RrVideo extends ServiceMap.Service()("@expect/browser/RrVi scaledViewport, totalTimeout, tempVideoDir, - replayConfig: { speed: options.speed, skipInactive: options.skipInactive }, + replayConfig: { + speed: options.speed, + skipInactive: options.skipInactive, + }, rrwebAssets, onProgress: options.onProgress, }); @@ -323,13 +430,13 @@ export class RrVideo extends ServiceMap.Service()("@expect/browser/RrVi .makeDirectory(path.dirname(options.outputPath), { recursive: true }) .pipe(Effect.catchReason("PlatformError", "AlreadyExists", () => Effect.void)); - yield* fileSystem - .copyFile(tempVideoPath, options.outputPath) - .pipe( - Effect.catchTag("PlatformError", (cause) => - new RrVideoConvertError({ cause: `Failed to copy video: ${cause}` }).asEffect(), - ), - ); + yield* fileSystem.copyFile(tempVideoPath, options.outputPath).pipe( + Effect.catchTag("PlatformError", (cause) => + new RrVideoConvertError({ + cause: `Failed to copy video: ${cause}`, + }).asEffect(), + ), + ); yield* Effect.logInfo("rrweb session converted to video", { outputPath: options.outputPath, @@ -340,7 +447,7 @@ export class RrVideo extends ServiceMap.Service()("@expect/browser/RrVi return options.outputPath; }, Effect.scoped); - return { convert } as const; + return { convert, convertEvents } as const; }), }) { static layer = Layer.effect(this)(this.make).pipe(Layer.provide(NodeServices.layer)); diff --git a/packages/browser/src/runtime/index.ts b/packages/browser/src/runtime/index.ts index eaa34917e..8f60b0cae 100644 --- a/packages/browser/src/runtime/index.ts +++ b/packages/browser/src/runtime/index.ts @@ -4,7 +4,14 @@ export type { PerformanceTrace } from "./performance"; export { injectOverlayLabels, removeOverlay, findCursorInteractiveElements } from "./overlay"; export type { CursorInteractiveResult } from "./overlay"; -export { startRecording, stopRecording, getEvents, getAllEvents, getEventCount } from "./rrweb"; +export { + startRecording, + stopRecording, + getEvents, + getAllEvents, + getEventCount, + isRecording, +} from "./rrweb"; export { prepareViewportSnapshot, restoreViewportSnapshot } from "./scroll-detection"; export type { ScrollContainerResult } from "./scroll-detection"; diff --git a/packages/browser/src/runtime/rrweb.ts b/packages/browser/src/runtime/rrweb.ts index 6886c0489..ec668dae0 100644 --- a/packages/browser/src/runtime/rrweb.ts +++ b/packages/browser/src/runtime/rrweb.ts @@ -35,3 +35,7 @@ export const getAllEvents = (): eventWithTime[] => { export const getEventCount = (): number => { return eventBuffer.length; }; + +export const isRecording = (): boolean => { + return stopFn !== undefined; +}; diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 209d7b990..1d4b3223f 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -1,7 +1,7 @@ import type { eventWithTime } from "@rrweb/types"; import type { Effect } from "effect"; -import type { Cookie } from "@expect/cookies"; import type { Locator, Page } from "playwright"; +import type { Cookie } from "@expect/shared/models"; import type { RefNotFoundError } from "./errors"; export type AriaRole = Parameters[0]; @@ -44,18 +44,6 @@ export interface SnapshotResult { locator: (ref: string) => Effect.Effect; } -export type BrowserEngine = "chromium" | "webkit" | "firefox"; - -export interface CreatePageOptions { - headed?: boolean; - executablePath?: string; - cookies?: boolean | Cookie[]; - waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit"; - videoOutputDir?: string; - cdpUrl?: string; - browserType?: BrowserEngine; -} - export interface AnnotatedScreenshotOptions extends SnapshotOptions { fullPage?: boolean; } diff --git a/packages/browser/src/utils/evaluate-runtime.ts b/packages/browser/src/utils/evaluate-runtime.ts index c7e19b734..f128f6f64 100644 --- a/packages/browser/src/utils/evaluate-runtime.ts +++ b/packages/browser/src/utils/evaluate-runtime.ts @@ -1,6 +1,7 @@ import type { Page } from "playwright"; import { Effect } from "effect"; import type { ExpectRuntime } from "../generated/runtime-types"; +import { BrowserLaunchError } from "../errors"; // HACK: page.evaluate erases types across the serialization boundary; casts are confined here export const evaluateRuntime = ( @@ -8,8 +9,8 @@ export const evaluateRuntime = ( method: K, ...args: Parameters ) => - Effect.promise( - () => + Effect.tryPromise({ + try: () => page.evaluate( ({ method, args }: { method: string; args: unknown[] }) => { const runtime = Reflect.get(globalThis, "__EXPECT_RUNTIME__"); @@ -26,4 +27,5 @@ export const evaluateRuntime = ( }, { method, args: args as unknown[] }, ) as Promise>, - ); + catch: (cause) => new BrowserLaunchError({ cause }), + }); diff --git a/packages/browser/tests/act.test.ts b/packages/browser/tests/act.test.ts index 6a165de33..ef1f52f26 100644 --- a/packages/browser/tests/act.test.ts +++ b/packages/browser/tests/act.test.ts @@ -1,154 +1,175 @@ -import { Effect } from "effect"; -import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; -import { chromium } from "playwright"; -import type { Browser as PlaywrightBrowser, Page } from "playwright"; -import { Browser } from "../src/browser"; - -const run =
    (effect: Effect.Effect) => Effect.runPromise(effect); - -const snapshotPage = (page: Page, options = {}) => - Effect.gen(function* () { - const browser = yield* Browser; - return yield* browser.snapshot(page, options); - }).pipe(Effect.provide(Browser.layer)); - -const actOnPage = ( - page: Page, - ref: string, - action: (locator: import("playwright").Locator) => Promise, - options?: Record, -) => - Effect.gen(function* () { - const browser = yield* Browser; - return yield* browser.act(page, ref, action, options); - }).pipe(Effect.provide(Browser.layer)); +import { Effect, Layer, Option } from "effect"; +import { describe, it, expect } from "vite-plus/test"; +import { Playwright } from "../src/playwright"; +import { Artifacts } from "../src/artifacts"; -describe("act", () => { - let playwrightBrowser: PlaywrightBrowser; - let page: Page; +const playwrightLayer = Playwright.layer.pipe(Layer.provide(Artifacts.layerTest(() => {}))); - beforeAll(async () => { - playwrightBrowser = await chromium.launch({ headless: true }); - const context = await playwrightBrowser.newContext(); - page = await context.newPage(); - }); +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(playwrightLayer))); - afterAll(async () => { - await playwrightBrowser.close(); - }); +const withPage = ( + content: string, + fn: (pw: typeof Playwright.Service) => Effect.Effect, +) => + run( + Effect.gen(function* () { + const pw = yield* Playwright; + yield* pw.open({ + headless: true, + browserProfile: Option.none(), + initialNavigation: Option.none(), + cdpUrl: Option.none(), + }); + const page = yield* pw.getPage; + yield* Effect.tryPromise(() => page.setContent(content)); + return yield* fn(pw); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const pw = yield* Playwright; + if (pw.hasSession()) yield* pw.close(); + }), + ), + ), + ); +describe("act", () => { it("should perform a click action and return updated snapshot", async () => { - await page.setContent( + await withPage( ``, + (pw) => + Effect.gen(function* () { + const before = yield* pw.snapshot({}); + const buttonRef = Object.keys(before.refs).find( + (key) => before.refs[key].name === "Click Me", + ); + expect(buttonRef).toBeDefined(); + + const after = yield* pw.act(buttonRef!, (locator) => locator.click()); + expect(after.tree).toContain("Clicked!"); + }), ); - - const before = await run(snapshotPage(page)); - const buttonRef = Object.keys(before.refs).find((key) => before.refs[key].name === "Click Me"); - expect(buttonRef).toBeDefined(); - - const after = await run(actOnPage(page, buttonRef!, (locator) => locator.click())); - expect(after.tree).toContain("Clicked!"); }); it("should perform a fill action and return updated snapshot", async () => { - await page.setContent( + await withPage( ``, + (pw) => + Effect.gen(function* () { + const before = yield* pw.snapshot({}); + const inputRef = Object.keys(before.refs).find( + (key) => before.refs[key].role === "textbox", + ); + expect(inputRef).toBeDefined(); + + const after = yield* pw.act(inputRef!, (locator) => locator.fill("Alice")); + expect(after.tree).toContain("Alice"); + }), ); - - const before = await run(snapshotPage(page)); - const inputRef = Object.keys(before.refs).find((key) => before.refs[key].role === "textbox"); - expect(inputRef).toBeDefined(); - - const after = await run(actOnPage(page, inputRef!, (locator) => locator.fill("Alice"))); - expect(after.tree).toContain("Alice"); }); it("should toggle a checkbox via act", async () => { - await page.setContent( + await withPage( ``, + (pw) => + Effect.gen(function* () { + const before = yield* pw.snapshot({}); + const checkboxRef = Object.keys(before.refs).find( + (key) => before.refs[key].role === "checkbox", + ); + expect(checkboxRef).toBeDefined(); + + yield* pw.act(checkboxRef!, (locator) => locator.check()); + const page = yield* pw.getPage; + const isChecked = yield* Effect.tryPromise(() => + page.locator('[aria-label="Accept terms"]').isChecked(), + ); + expect(isChecked).toBe(true); + }), ); - - const before = await run(snapshotPage(page)); - const checkboxRef = Object.keys(before.refs).find( - (key) => before.refs[key].role === "checkbox", - ); - expect(checkboxRef).toBeDefined(); - - await run(actOnPage(page, checkboxRef!, (locator) => locator.check())); - const isChecked = await page.locator('[aria-label="Accept terms"]').isChecked(); - expect(isChecked).toBe(true); }); it("should return a snapshot with valid refs after action", async () => { - await page.setContent( + await withPage( ``, + (pw) => + Effect.gen(function* () { + const before = yield* pw.snapshot({}); + const buttonRef = Object.keys(before.refs).find( + (key) => before.refs[key].role === "button", + ); + expect(buttonRef).toBeDefined(); + + const after = yield* pw.act(buttonRef!, (locator) => locator.click()); + expect(after.tree).toContain("link"); + expect(after.tree).toContain("New Link"); + + const linkRef = Object.keys(after.refs).find((key) => after.refs[key].role === "link"); + expect(linkRef).toBeDefined(); + const locator = yield* after.locator(linkRef!); + expect(yield* Effect.tryPromise(() => locator.textContent())).toBe("New Link"); + }), ); - - const before = await run(snapshotPage(page)); - const buttonRef = Object.keys(before.refs).find((key) => before.refs[key].role === "button"); - expect(buttonRef).toBeDefined(); - - const after = await run(actOnPage(page, buttonRef!, (locator) => locator.click())); - expect(after.tree).toContain("link"); - expect(after.tree).toContain("New Link"); - - const linkRef = Object.keys(after.refs).find((key) => after.refs[key].role === "link"); - expect(linkRef).toBeDefined(); - - const locator = await run(after.locator(linkRef!)); - expect(await locator.textContent()).toBe("New Link"); }); it("should forward snapshot options", async () => { - await page.setContent( + await withPage( `

    Title

    `, + (pw) => + Effect.gen(function* () { + const interactiveBefore = yield* pw.snapshot({ interactive: true }); + const buttonRef = Object.keys(interactiveBefore.refs).find( + (key) => interactiveBefore.refs[key].role === "button", + ); + expect(buttonRef).toBeDefined(); + + const after = yield* pw.act(buttonRef!, (locator) => locator.click(), { + interactive: true, + }); + const roles = Object.values(after.refs).map((entry) => entry.role); + expect(roles).not.toContain("heading"); + expect(roles).toContain("button"); + }), ); - - const interactiveBefore = await run(snapshotPage(page, { interactive: true })); - const buttonRef = Object.keys(interactiveBefore.refs).find( - (key) => interactiveBefore.refs[key].role === "button", - ); - expect(buttonRef).toBeDefined(); - - const after = await run( - actOnPage(page, buttonRef!, (locator) => locator.click(), { interactive: true }), - ); - - const roles = Object.values(after.refs).map((entry) => entry.role); - expect(roles).not.toContain("heading"); - expect(roles).toContain("button"); }); it("should handle actions on duplicate-named elements", async () => { - await page.setContent( + await withPage( ``, + (pw) => + Effect.gen(function* () { + const before = yield* pw.snapshot({}); + const goButtons = Object.entries(before.refs).filter( + ([, entry]) => entry.role === "button" && entry.name === "Go", + ); + expect(goButtons.length).toBe(2); + + yield* pw.act(goButtons[1][0], (locator) => locator.click()); + const page = yield* pw.getPage; + expect(yield* Effect.tryPromise(() => page.title())).toBe("second"); + }), ); - - const before = await run(snapshotPage(page)); - const goButtons = Object.entries(before.refs).filter( - ([, entry]) => entry.role === "button" && entry.name === "Go", - ); - expect(goButtons.length).toBe(2); - - await run(actOnPage(page, goButtons[1][0], (locator) => locator.click())); - expect(await page.title()).toBe("second"); }); it("should handle select option action", async () => { - await page.setContent( + await withPage( ``, + (pw) => + Effect.gen(function* () { + const before = yield* pw.snapshot({}); + const selectRef = Object.keys(before.refs).find( + (key) => before.refs[key].role === "combobox", + ); + expect(selectRef).toBeDefined(); + + yield* pw.act(selectRef!, async (locator) => { + await locator.selectOption("banana"); + }); + const page = yield* pw.getPage; + const value = yield* Effect.tryPromise(() => page.locator("#fruit").inputValue()); + expect(value).toBe("banana"); + }), ); - - const before = await run(snapshotPage(page)); - const selectRef = Object.keys(before.refs).find((key) => before.refs[key].role === "combobox"); - expect(selectRef).toBeDefined(); - - await run( - actOnPage(page, selectRef!, async (locator) => { - await locator.selectOption("banana"); - }), - ); - const value = await page.locator("#fruit").inputValue(); - expect(value).toBe("banana"); }); }); diff --git a/packages/browser/tests/browser-e2e.test.ts b/packages/browser/tests/browser-e2e.test.ts index fe9f19591..1f6f20fcc 100644 --- a/packages/browser/tests/browser-e2e.test.ts +++ b/packages/browser/tests/browser-e2e.test.ts @@ -1,8 +1,37 @@ -import { Effect } from "effect"; +import { Effect, Layer, Option } from "effect"; import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; import * as http from "node:http"; -import { runBrowser } from "../src/browser"; -import type { BrowserEngine } from "../src/types"; +import { Playwright } from "../src/playwright"; +import { Artifacts } from "../src/artifacts"; + +const playwrightLayer = Playwright.layer.pipe(Layer.provide(Artifacts.layerTest(() => {}))); + +const run =
    (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(playwrightLayer))); + +const withSession = ( + url: string, + fn: (pw: typeof Playwright.Service) => Effect.Effect, +) => + run( + Effect.gen(function* () { + const pw = yield* Playwright; + yield* pw.open({ + headless: true, + browserProfile: Option.none(), + initialNavigation: Option.some({ url, waitUntil: "domcontentloaded" as const }), + cdpUrl: Option.none(), + }); + return yield* fn(pw); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const pw = yield* Playwright; + if (pw.hasSession()) yield* pw.close(); + }), + ), + ), + ); interface RecordedRequest { method: string; @@ -132,264 +161,145 @@ describe("browser e2e", () => { }); it("creates a real page and snapshots interactive content", async () => { - const session = await runBrowser((browser) => - browser.createPage(origin, { waitUntil: "domcontentloaded" }), + await withSession(origin, (pw) => + Effect.gen(function* () { + const snapshot = yield* pw.snapshot({ interactive: true }); + expect(snapshot.tree).toContain(`textbox "Workspace name"`); + expect(snapshot.tree).toContain(`button "Open"`); + expect(snapshot.tree).toContain(`button "Save settings"`); + expect(snapshot.tree).toContain(`button "Continue"`); + expect(snapshot.stats.interactiveRefs).toBeGreaterThanOrEqual(5); + }), ); - - try { - expect(session.page.url()).toBe(`${origin}/`); - - const snapshot = await runBrowser((browser) => - browser.snapshot(session.page, { interactive: true }), - ); - - expect(snapshot.tree).toContain(`textbox "Workspace name"`); - expect(snapshot.tree).toContain(`button "Open"`); - expect(snapshot.tree).toContain(`button "Save settings"`); - expect(snapshot.tree).toContain(`button "Continue"`); - expect(snapshot.stats.interactiveRefs).toBeGreaterThanOrEqual(5); - } finally { - await session.browser.close(); - } }); - it("fills state through Browser.act and preserves the updated value in snapshots", async () => { - const session = await runBrowser((browser) => browser.createPage(origin)); - - try { - const before = await runBrowser((browser) => - browser.snapshot(session.page, { interactive: true }), - ); - const nameRef = Object.keys(before.refs).find( - (key) => before.refs[key].role === "textbox" && before.refs[key].name === "Workspace name", - ); - - expect(nameRef).toBeDefined(); - - const after = await runBrowser((browser) => - browser.act(session.page, nameRef!, (locator) => locator.fill("Browser smoke"), { + it("fills state through act and preserves the updated value in snapshots", async () => { + await withSession(origin, (pw) => + Effect.gen(function* () { + const before = yield* pw.snapshot({ interactive: true }); + const nameRef = Object.keys(before.refs).find( + (key) => + before.refs[key].role === "textbox" && before.refs[key].name === "Workspace name", + ); + expect(nameRef).toBeDefined(); + + const after = yield* pw.act(nameRef!, (locator) => locator.fill("Browser smoke"), { interactive: true, - }), - ); - - expect(after.tree).toContain("Browser smoke"); - expect(await session.page.locator("#workspace-name").inputValue()).toBe("Browser smoke"); - } finally { - await session.browser.close(); - } + }); + expect(after.tree).toContain("Browser smoke"); + + const page = yield* pw.getPage; + expect(yield* Effect.tryPromise(() => page.locator("#workspace-name").inputValue())).toBe( + "Browser smoke", + ); + }), + ); }); it("resolves duplicate refs and saves settings through a real network roundtrip", async () => { requests.length = 0; - const session = await runBrowser((browser) => browser.createPage(origin)); - - try { - await session.page.locator("#workspace-name").fill("Browser smoke"); - - const snapshot = await runBrowser((browser) => - browser.snapshot(session.page, { interactive: true }), - ); - - const openRefs = Object.entries(snapshot.refs).filter( - ([, entry]) => entry.role === "button" && entry.name === "Open", - ); - expect(openRefs).toHaveLength(2); - expect(openRefs.map(([, entry]) => entry.nth)).toEqual([0, 1]); - - const betaOpenLocator = await Effect.runPromise(snapshot.locator(openRefs[1][0])); - await betaOpenLocator.click(); - - expect(await session.page.locator("#active-workspace").textContent()).toBe("beta"); - - const saveRef = Object.keys(snapshot.refs).find( - (key) => - snapshot.refs[key].role === "button" && snapshot.refs[key].name === "Save settings", - ); - expect(saveRef).toBeDefined(); - - const saveLocator = await Effect.runPromise(snapshot.locator(saveRef!)); - await saveLocator.click(); - - await session.page.waitForFunction( - () => document.getElementById("status")?.textContent === "Saved Browser smoke for beta", - ); - expect(await session.page.locator("#status").textContent()).toBe( - "Saved Browser smoke for beta", - ); - - const apiRequest = requests.find((request) => request.path === "/api/settings"); - expect(apiRequest).toBeDefined(); - expect(apiRequest?.method).toBe("POST"); - expect(apiRequest?.body).toContain(`"workspaceName":"Browser smoke"`); - expect(apiRequest?.body).toContain(`"activeWorkspace":"beta"`); - - const savedSnapshot = await runBrowser((browser) => browser.snapshot(session.page)); - expect(savedSnapshot.tree).toContain(`paragraph: beta`); - expect(savedSnapshot.tree).toContain(`paragraph: Saved Browser smoke for beta`); - } finally { - await session.browser.close(); - } + await withSession(origin, (pw) => + Effect.gen(function* () { + const page = yield* pw.getPage; + yield* Effect.tryPromise(() => page.locator("#workspace-name").fill("Browser smoke")); + + const snapshot = yield* pw.snapshot({ interactive: true }); + + const openRefs = Object.entries(snapshot.refs).filter( + ([, entry]) => entry.role === "button" && entry.name === "Open", + ); + expect(openRefs).toHaveLength(2); + expect(openRefs.map(([, entry]) => entry.nth)).toEqual([0, 1]); + + const betaOpenLocator = yield* snapshot.locator(openRefs[1][0]); + yield* Effect.tryPromise(() => betaOpenLocator.click()); + + expect( + yield* Effect.tryPromise(() => page.locator("#active-workspace").textContent()), + ).toBe("beta"); + + const saveRef = Object.keys(snapshot.refs).find( + (key) => + snapshot.refs[key].role === "button" && snapshot.refs[key].name === "Save settings", + ); + expect(saveRef).toBeDefined(); + + const saveLocator = yield* snapshot.locator(saveRef!); + yield* Effect.tryPromise(() => saveLocator.click()); + + yield* Effect.tryPromise(() => + page.waitForFunction( + () => document.getElementById("status")?.textContent === "Saved Browser smoke for beta", + ), + ); + expect(yield* Effect.tryPromise(() => page.locator("#status").textContent())).toBe( + "Saved Browser smoke for beta", + ); + + const apiRequest = requests.find((request) => request.path === "/api/settings"); + expect(apiRequest).toBeDefined(); + expect(apiRequest?.method).toBe("POST"); + expect(apiRequest?.body).toContain(`"workspaceName":"Browser smoke"`); + expect(apiRequest?.body).toContain(`"activeWorkspace":"beta"`); + + const savedSnapshot = yield* pw.snapshot({}); + expect(savedSnapshot.tree).toContain(`paragraph: beta`); + expect(savedSnapshot.tree).toContain(`paragraph: Saved Browser smoke for beta`); + }), + ); }); it("supports selector-scoped snapshots for a focused part of the page", async () => { - const session = await runBrowser((browser) => browser.createPage(origin)); - - try { - const result = await runBrowser((browser) => - browser.snapshot(session.page, { + await withSession(origin, (pw) => + Effect.gen(function* () { + const result = yield* pw.snapshot({ selector: 'section[aria-label="Available workspaces"]', interactive: true, compact: true, - }), - ); - - expect(result.tree).toContain(`button "Open"`); - expect(result.tree).not.toContain(`textbox "Workspace name"`); - expect(result.stats.interactiveRefs).toBe(2); - } finally { - await session.browser.close(); - } - }); - - it("returns annotated screenshots for interactive elements after building the runtime", async () => { - const session = await runBrowser((browser) => browser.createPage(origin)); - - try { - const result = await runBrowser((browser) => - browser.annotatedScreenshot(session.page, { interactive: true, fullPage: true }), - ); - - expect(result.screenshot.byteLength).toBeGreaterThan(0); - expect(result.annotations.length).toBeGreaterThanOrEqual(5); - expect(result.annotations.some((annotation) => annotation.name === "Workspace name")).toBe( - true, - ); - expect(result.annotations.filter((annotation) => annotation.name === "Open")).toHaveLength(2); - } finally { - await session.browser.close(); - } - }); - - it("waits for client-side navigation to settle after a click", async () => { - const session = await runBrowser((browser) => browser.createPage(origin)); - - try { - const urlBefore = session.page.url(); - - await session.page.getByRole("button", { name: "Continue" }).click(); - await runBrowser((browser) => browser.waitForNavigationSettle(session.page, urlBefore)); - - expect(session.page.url()).toBe(`${origin}/done`); + }); - const snapshot = await runBrowser((browser) => browser.snapshot(session.page)); - expect(snapshot.tree).toContain(`heading "Setup complete"`); - } finally { - await session.browser.close(); - } - }); -}); - -const tryLaunchEngine = async (engineName: BrowserEngine, testOrigin: string) => { - try { - const session = await runBrowser((browser) => - browser.createPage(testOrigin, { browserType: engineName, waitUntil: "domcontentloaded" }), + expect(result.tree).toContain(`button "Open"`); + expect(result.tree).not.toContain(`textbox "Workspace name"`); + expect(result.stats.interactiveRefs).toBe(2); + }), ); - return session; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("Executable doesn't exist")) return undefined; - throw error; - } -}; - -describe("cross-browser engine support", () => { - let server: http.Server; - let origin: string; - - beforeAll(async () => { - const app = await startBrowserApp(); - server = app.server; - origin = app.origin; - }); - - afterAll(async () => { - await new Promise((resolve) => { - server.close(() => resolve()); - }); }); - it("launches webkit and snapshots the page", async () => { - const session = await tryLaunchEngine("webkit", origin); - if (!session) return; - - try { - expect(session.page.url()).toBe(`${origin}/`); - - const snapshot = await runBrowser((browser) => browser.snapshot(session.page)); - expect(snapshot.tree).toContain(`heading "Workspace setup"`); - expect(snapshot.tree).toContain(`button "Save settings"`); - } finally { - await session.browser.close(); - } - }); - - it("launches firefox and snapshots the page", async () => { - const session = await tryLaunchEngine("firefox", origin); - if (!session) return; - - try { - expect(session.page.url()).toBe(`${origin}/`); - - const snapshot = await runBrowser((browser) => browser.snapshot(session.page)); - expect(snapshot.tree).toContain(`heading "Workspace setup"`); - expect(snapshot.tree).toContain(`button "Save settings"`); - } finally { - await session.browser.close(); - } + it("returns annotated screenshots for interactive elements", async () => { + await withSession(origin, (pw) => + Effect.gen(function* () { + const result = yield* pw.annotatedScreenshot({ + interactive: true, + fullPage: true, + }); + + expect(result.screenshot.byteLength).toBeGreaterThan(0); + expect(result.annotations.length).toBeGreaterThanOrEqual(5); + expect(result.annotations.some((annotation) => annotation.name === "Workspace name")).toBe( + true, + ); + expect(result.annotations.filter((annotation) => annotation.name === "Open")).toHaveLength( + 2, + ); + }), + ); }); - it("webkit session supports interaction via act", async () => { - const session = await tryLaunchEngine("webkit", origin); - if (!session) return; + it("waits for client-side navigation to settle after a click", async () => { + await withSession(origin, (pw) => + Effect.gen(function* () { + const page = yield* pw.getPage; + const urlBefore = page.url(); - try { - const snapshot = await runBrowser((browser) => - browser.snapshot(session.page, { interactive: true }), - ); - const nameRef = Object.keys(snapshot.refs).find( - (key) => - snapshot.refs[key].role === "textbox" && snapshot.refs[key].name === "Workspace name", - ); - expect(nameRef).toBeDefined(); + yield* Effect.tryPromise(() => page.getByRole("button", { name: "Continue" }).click()); + yield* pw.waitForNavigationSettle(urlBefore); - const after = await runBrowser((browser) => - browser.act(session.page, nameRef!, (locator) => locator.fill("WebKit test"), { - interactive: true, - }), - ); - expect(after.tree).toContain("WebKit test"); - } finally { - await session.browser.close(); - } - }); + expect(page.url()).toBe(`${origin}/done`); - it("can switch between chromium and webkit sessions", async () => { - const chromiumSession = await runBrowser((browser) => - browser.createPage(origin, { waitUntil: "domcontentloaded" }), + const snapshot = yield* pw.snapshot({}); + expect(snapshot.tree).toContain(`heading "Setup complete"`); + }), ); - const chromiumSnapshot = await runBrowser((browser) => browser.snapshot(chromiumSession.page)); - expect(chromiumSnapshot.tree).toContain(`heading "Workspace setup"`); - await chromiumSession.browser.close(); - - const webkitSession = await tryLaunchEngine("webkit", origin); - if (!webkitSession) return; - - try { - const webkitSnapshot = await runBrowser((browser) => browser.snapshot(webkitSession.page)); - expect(webkitSnapshot.tree).toContain(`heading "Workspace setup"`); - } finally { - await webkitSession.browser.close(); - } }); }); diff --git a/packages/browser/tests/create-page.test.ts b/packages/browser/tests/create-page.test.ts deleted file mode 100644 index 1501e4619..000000000 --- a/packages/browser/tests/create-page.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const { - defaultBrowserMock, - browserListMock, - cookieExtractMock, - launchMock, - newContextMock, - addCookiesMock, - addInitScriptMock, - pagesMock, - newPageMock, - gotoMock, - closeMock, -} = vi.hoisted(() => ({ - defaultBrowserMock: vi.fn(), - browserListMock: vi.fn(), - cookieExtractMock: vi.fn(), - launchMock: vi.fn(), - newContextMock: vi.fn(), - addCookiesMock: vi.fn(), - addInitScriptMock: vi.fn(), - pagesMock: vi.fn(), - newPageMock: vi.fn(), - gotoMock: vi.fn(), - closeMock: vi.fn(), -})); - -vi.mock("@expect/cookies", async () => { - const { Effect, Layer, ServiceMap } = await import("effect"); - - class Browsers extends ServiceMap.Service()("@cookies/Browsers", { - make: Effect.succeed({ - defaultBrowser: () => Effect.suspend(() => defaultBrowserMock()), - list: Effect.suspend(() => browserListMock()), - register: () => Effect.void, - }), - }) {} - - class Cookies extends ServiceMap.Service()("@cookies/Cookies", { - make: Effect.succeed({ - extract: (profile: unknown) => Effect.suspend(() => cookieExtractMock(profile)), - }), - }) { - static layer = Layer.effect(this, this.make); - } - - return { - Browsers, - Cookies, - layerLive: Layer.effect(Browsers, Browsers.make), - }; -}); - -const webkitLaunchMock = vi.hoisted(() => vi.fn()); -const firefoxLaunchMock = vi.hoisted(() => vi.fn()); - -vi.mock("playwright", () => ({ - chromium: { - launch: launchMock, - }, - webkit: { - launch: webkitLaunchMock, - }, - firefox: { - launch: firefoxLaunchMock, - }, -})); - -import { Effect, Option } from "effect"; -import { runBrowser } from "../src/browser"; - -const heliumProfile = { - _tag: "ChromiumBrowser" as const, - key: "helium", - profileName: "Default", - profilePath: "/tmp/helium/Default", - executablePath: "/usr/bin/helium", - locale: "en-US", -}; - -const workProfile = { - _tag: "ChromiumBrowser" as const, - key: "helium", - profileName: "Profile 1", - profilePath: "/tmp/helium/Profile 1", - executablePath: "/usr/bin/helium", - locale: "en-US", -}; - -const mockCookie = (data: Record) => ({ - ...data, - get playwrightFormat() { - const domain = String(data.domain); - return { - name: data.name, - value: data.value, - domain: domain.startsWith(".") ? domain : `.${domain}`, - path: data.path, - expires: -1, - secure: data.secure, - httpOnly: data.httpOnly, - sameSite: data.sameSite, - }; - }, -}); - -const profileCookies = [ - mockCookie({ - name: "__Host-session", - value: "profile-cookie", - domain: "github.com", - path: "/", - secure: true, - httpOnly: true, - sameSite: "Strict", - }), -]; - -const fallbackCookies = [ - mockCookie({ - name: "fallback-session", - value: "sqlite-cookie", - domain: "github.com", - path: "/", - secure: true, - httpOnly: true, - sameSite: "Lax", - }), -]; - -describe("Browser.createPage cookie reuse", () => { - beforeEach(() => { - vi.clearAllMocks(); - - gotoMock.mockResolvedValue(undefined); - newPageMock.mockResolvedValue({ goto: gotoMock }); - addCookiesMock.mockResolvedValue(undefined); - addInitScriptMock.mockResolvedValue(undefined); - pagesMock.mockReturnValue([]); - newContextMock.mockResolvedValue({ - newPage: newPageMock, - addCookies: addCookiesMock, - addInitScript: addInitScriptMock, - pages: pagesMock, - }); - closeMock.mockResolvedValue(undefined); - launchMock.mockResolvedValue({ - newContext: newContextMock, - close: closeMock, - }); - - defaultBrowserMock.mockReturnValue(Effect.succeed(Option.some(heliumProfile))); - browserListMock.mockReturnValue(Effect.succeed([heliumProfile, workProfile])); - cookieExtractMock.mockReturnValue(Effect.succeed(profileCookies)); - }); - - it("extracts cookies from all profiles of the same browser", async () => { - cookieExtractMock.mockReturnValue(Effect.succeed(profileCookies)); - - await runBrowser((browser) => browser.createPage("https://github.com", { cookies: true })); - - expect(newContextMock).toHaveBeenCalledWith({ locale: "en-US" }); - expect(cookieExtractMock).toHaveBeenCalledWith(heliumProfile); - expect(cookieExtractMock).toHaveBeenCalledWith(workProfile); - expect(addCookiesMock).toHaveBeenCalledWith( - profileCookies.map((cookie) => cookie.playwrightFormat), - ); - }); - - it("merges unique cookies across profiles", async () => { - const workCookies = [ - mockCookie({ - name: "wos-session", - value: "work-session-token", - domain: "localhost", - path: "/", - secure: true, - httpOnly: true, - sameSite: "Lax", - }), - ]; - - cookieExtractMock.mockImplementation((profile: unknown) => { - if (profile === heliumProfile) return Effect.succeed(profileCookies); - return Effect.succeed(workCookies); - }); - - await runBrowser((browser) => browser.createPage("https://github.com", { cookies: true })); - - expect(addCookiesMock).toHaveBeenCalledWith( - [...profileCookies, ...workCookies].map((cookie) => cookie.playwrightFormat), - ); - }); - - it("preferred profile cookies win when duplicates exist across profiles", async () => { - const preferredVersion = [ - mockCookie({ - name: "session", - value: "preferred-value", - domain: "example.com", - path: "/", - secure: true, - httpOnly: true, - sameSite: "Lax", - }), - ]; - - const otherVersion = [ - mockCookie({ - name: "session", - value: "other-value", - domain: "example.com", - path: "/", - secure: true, - httpOnly: true, - sameSite: "Lax", - }), - ]; - - cookieExtractMock.mockImplementation((profile: unknown) => { - if (profile === heliumProfile) return Effect.succeed(preferredVersion); - return Effect.succeed(otherVersion); - }); - - await runBrowser((browser) => browser.createPage("https://github.com", { cookies: true })); - - expect(addCookiesMock).toHaveBeenCalledWith( - preferredVersion.map((cookie) => cookie.playwrightFormat), - ); - }); - - it("uses other profiles when preferred returns no cookies", async () => { - cookieExtractMock.mockImplementation((profile: unknown) => { - if (profile === heliumProfile) return Effect.succeed([]); - return Effect.succeed(fallbackCookies); - }); - - await runBrowser((browser) => browser.createPage("https://github.com", { cookies: true })); - - expect(addCookiesMock).toHaveBeenCalledWith( - fallbackCookies.map((cookie) => cookie.playwrightFormat), - ); - }); -}); - -describe("Browser.createPage browserType", () => { - beforeEach(() => { - vi.clearAllMocks(); - - gotoMock.mockResolvedValue(undefined); - newPageMock.mockResolvedValue({ goto: gotoMock }); - addCookiesMock.mockResolvedValue(undefined); - addInitScriptMock.mockResolvedValue(undefined); - pagesMock.mockReturnValue([]); - newContextMock.mockResolvedValue({ - newPage: newPageMock, - addCookies: addCookiesMock, - addInitScript: addInitScriptMock, - pages: pagesMock, - }); - closeMock.mockResolvedValue(undefined); - - const mockBrowser = { - newContext: newContextMock, - close: closeMock, - }; - launchMock.mockResolvedValue(mockBrowser); - webkitLaunchMock.mockResolvedValue(mockBrowser); - firefoxLaunchMock.mockResolvedValue(mockBrowser); - - defaultBrowserMock.mockReturnValue(Effect.succeed(Option.none())); - browserListMock.mockReturnValue(Effect.succeed([])); - }); - - it("defaults to chromium when browserType is not specified", async () => { - await runBrowser((browser) => browser.createPage("https://example.com")); - - expect(launchMock).toHaveBeenCalledOnce(); - expect(webkitLaunchMock).not.toHaveBeenCalled(); - expect(firefoxLaunchMock).not.toHaveBeenCalled(); - }); - - it("launches webkit when browserType is webkit", async () => { - await runBrowser((browser) => - browser.createPage("https://example.com", { browserType: "webkit" }), - ); - - expect(webkitLaunchMock).toHaveBeenCalledOnce(); - expect(launchMock).not.toHaveBeenCalled(); - expect(firefoxLaunchMock).not.toHaveBeenCalled(); - }); - - it("launches firefox when browserType is firefox", async () => { - await runBrowser((browser) => - browser.createPage("https://example.com", { browserType: "firefox" }), - ); - - expect(firefoxLaunchMock).toHaveBeenCalledOnce(); - expect(launchMock).not.toHaveBeenCalled(); - expect(webkitLaunchMock).not.toHaveBeenCalled(); - }); - - it("does not pass chromium-specific args for non-chromium engines", async () => { - await runBrowser((browser) => - browser.createPage("https://example.com", { browserType: "webkit" }), - ); - - expect(webkitLaunchMock).toHaveBeenCalledWith(expect.objectContaining({ args: [] })); - }); -}); diff --git a/packages/browser/tests/live-view-server.test.ts b/packages/browser/tests/live-view-server.test.ts deleted file mode 100644 index ff42f8389..000000000 --- a/packages/browser/tests/live-view-server.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { afterEach, describe, expect, it } from "vite-plus/test"; -import { Effect } from "effect"; -import { startLiveViewServer, type LiveViewHandle } from "../src/mcp/live-view-server"; - -const findAvailablePort = async (): Promise => { - const { createServer } = await import("node:http"); - return new Promise((resolve) => { - const server = createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; - server.close(() => resolve(port)); - }); - }); -}; - -const startServer = (port: number, options?: Partial[0]>) => - Effect.runPromise( - startLiveViewServer({ - liveViewUrl: `http://127.0.0.1:${port}`, - getPage: () => undefined, - onEventsCollected: () => {}, - ...options, - }), - ); - -describe("startLiveViewServer", () => { - let server: LiveViewHandle | undefined; - - afterEach(async () => { - if (server) { - await Effect.runPromise(server.close); - server = undefined; - } - }); - - it("starts and serves the HTML viewer at /", async () => { - const port = await findAvailablePort(); - server = await startServer(port); - - const response = await fetch(`http://127.0.0.1:${port}/`); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("text/html"); - const html = await response.text(); - expect(html).toContain("Expect Live View"); - expect(html).toContain("rrweb-player"); - }); - - it("returns 404 for unknown routes", async () => { - const port = await findAvailablePort(); - server = await startServer(port); - - const response = await fetch(`http://127.0.0.1:${port}/unknown`); - expect(response.status).toBe(404); - }); - - it("serves accumulated events at /latest.json", async () => { - const port = await findAvailablePort(); - server = await startServer(port); - - const response = await fetch(`http://127.0.0.1:${port}/latest.json`); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("application/json"); - const events = await response.json(); - expect(events).toEqual([]); - }); - - it("exposes the server url", async () => { - const port = await findAvailablePort(); - server = await startServer(port); - - expect(server.url).toBe(`http://127.0.0.1:${port}/`); - }); - - it("opens an SSE connection at /events", async () => { - const port = await findAvailablePort(); - server = await startServer(port); - - const response = await fetch(`http://127.0.0.1:${port}/events`); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("text/event-stream"); - - response.body?.cancel(); - }); -}); diff --git a/packages/browser/tests/mcp-server.test.ts b/packages/browser/tests/mcp-server.test.ts index 00b1b1f75..997517162 100644 --- a/packages/browser/tests/mcp-server.test.ts +++ b/packages/browser/tests/mcp-server.test.ts @@ -3,8 +3,10 @@ import type { AddressInfo } from "node:net"; import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { McpRuntime } from "../src/mcp/runtime"; -import { createBrowserMcpServer } from "../src/mcp/server"; +import { ConfigProvider, Effect, Layer, ManagedRuntime } from "effect"; +import { NodeServices } from "@effect/platform-node"; +import { layerMcpServer, McpTransport } from "../src/mcp/index"; +import { Artifacts } from "../src/artifacts"; const TEST_HTML = ` @@ -25,7 +27,7 @@ let testServerUrl: string; let httpServer: ReturnType; let mcpClient: Client; -let mcpCleanup: () => Promise; +let runtime: ManagedRuntime.ManagedRuntime; const callTool = async (name: string, args: Record = {}) => { const result = await mcpClient.callTool({ name, arguments: args }); @@ -48,21 +50,28 @@ beforeAll(async () => { const port = (httpServer.address() as AddressInfo).port; testServerUrl = `http://127.0.0.1:${port}`; - const server = createBrowserMcpServer(McpRuntime); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const testLayer = layerMcpServer.pipe( + Layer.provide(Layer.succeed(McpTransport, serverTransport)), + Layer.provide(Artifacts.layerTest(() => {})), + Layer.provide( + ConfigProvider.layerAdd(ConfigProvider.fromUnknown({ EXPECT_PLAN_ID: "test-plan" })), + ), + Layer.provide(NodeServices.layer), + ); + + runtime = ManagedRuntime.make(testLayer); mcpClient = new Client({ name: "test-client", version: "0.0.1" }); - await server.connect(serverTransport); - await mcpClient.connect(clientTransport); - mcpCleanup = async () => { - await mcpClient.close(); - await server.close(); - }; -}); + // Build the runtime (starts MCP server) and connect client concurrently + await Promise.all([runtime.runPromise(Effect.void), mcpClient.connect(clientTransport)]); +}, 30_000); afterAll(async () => { await callTool("close").catch(() => {}); - await mcpCleanup(); + await mcpClient.close(); + await runtime.dispose(); httpServer.close(); }); @@ -70,16 +79,13 @@ describe("MCP server tools", () => { it("lists all tools", async () => { const tools = await mcpClient.listTools(); const toolNames = tools.tools.map((tool) => tool.name).sort(); - expect(toolNames).toEqual([ - "accessibility_audit", - "close", - "console_logs", - "network_requests", - "open", - "performance_metrics", - "playwright", - "screenshot", - ]); + expect(toolNames).toContain("open"); + expect(toolNames).toContain("screenshot"); + expect(toolNames).toContain("playwright"); + expect(toolNames).toContain("close"); + expect(toolNames).toContain("console_logs"); + expect(toolNames).toContain("network_requests"); + expect(toolNames).toContain("performance_metrics"); }); it("open → snapshot → playwright ref click → verify", async () => { @@ -101,12 +107,12 @@ describe("MCP server tools", () => { const fillResult = await callTool("playwright", { code: `await ref('${emailRef![0]}').fill('hello@test.com');`, }); - expect(textContent(fillResult)).toBe("OK"); + expect(textContent(fillResult)).toContain("OK"); const clickResult = await callTool("playwright", { code: `await ref('${submitRef![0]}').click();`, }); - expect(textContent(clickResult)).toBe("OK"); + expect(textContent(clickResult)).toContain("OK"); const verifyResult = await callTool("playwright", { code: `return await page.locator('#result').innerText();`, @@ -189,6 +195,7 @@ describe("MCP server tools", () => { it("playwright without snapshotAfter returns plain result", async () => { await callTool("open", { url: testServerUrl }); + await callTool("screenshot", { mode: "snapshot" }); const result = await callTool("playwright", { code: `return 42;`, }); @@ -213,7 +220,7 @@ describe("MCP server tools", () => { expect(schema.properties).toHaveProperty("browser"); }); - it("open with browser=webkit launches a webkit session", async () => { + it("open with browser=webkit launches a webkit session", { timeout: 15_000 }, async () => { const openResult = await callTool("open", { url: testServerUrl, browser: "webkit" }); const text = textContent(openResult); @@ -232,7 +239,7 @@ describe("MCP server tools", () => { await callTool("close"); }); - it("switches from chromium to webkit via close → open", async () => { + it("switches from chromium to webkit via close → open", { timeout: 15_000 }, async () => { const chromiumResult = await callTool("open", { url: testServerUrl }); expect(textContent(chromiumResult)).toContain("Opened"); expect(textContent(chromiumResult)).not.toContain("[webkit]"); @@ -259,7 +266,7 @@ describe("MCP server tools", () => { await callTool("close"); }); - it("open with browser=firefox launches a firefox session", async () => { + it("open with browser=firefox launches a firefox session", { timeout: 15_000 }, async () => { const openResult = await callTool("open", { url: testServerUrl, browser: "firefox" }); const text = textContent(openResult); diff --git a/packages/browser/tests/playwright.test.ts b/packages/browser/tests/playwright.test.ts new file mode 100644 index 000000000..b4332e141 --- /dev/null +++ b/packages/browser/tests/playwright.test.ts @@ -0,0 +1,97 @@ +import { Cause, ConfigProvider, Console, Duration, Effect, Layer, Option, Stream } from "effect"; +import { HttpClient, FetchHttpClient } from "effect/unstable/http"; +import { Playwright } from "../src/playwright"; +import { Artifacts } from "../src/artifacts"; +import { describe, it, assert } from "vite-plus/test"; +import { + layerArtifactRpcServer, + layerArtifactViewerProxy, +} from "../../../apps/cli/src/artifact-server"; +import { Git, ArtifactStore } from "@expect/supervisor"; +import { Artifact, CurrentPlanId, PlanId, RrwebEvent } from "@expect/shared/models"; +import { LIVE_VIEWER_STATIC_PORT } from "@expect/shared"; +import { ArtifactClient } from "../src/mcp/artifact-client"; + +const planId = PlanId.makeUnsafe(crypto.randomUUID()); + +const configLayer = ConfigProvider.layerAdd(ConfigProvider.fromUnknown({ EXPECT_PLAN_ID: planId })); + +const layerServer = Layer.mergeAll(layerArtifactRpcServer, layerArtifactViewerProxy).pipe( + Layer.provide(configLayer), + Layer.provide(Git.withRepoRoot(process.cwd())), + Layer.provide(Layer.succeed(CurrentPlanId, planId)), +); + +const layerPlaywright = Layer.mergeAll( + Playwright.layer, + ArtifactClient.layer, + FetchHttpClient.layer, +).pipe(Layer.provide(Artifacts.layer), Layer.provide(configLayer), Layer.provideMerge(layerServer)); + +const run = ( + effect: Effect.Effect, +) => + effect.pipe( + Effect.provide(layerPlaywright), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.die(cause), + ), + Effect.tapCause((cause) => { + return Console.log(Cause.pretty(cause)); + }), + Effect.runPromise, + ); + +describe("playwright e2e", () => { + it( + "viewer serves replay page", + () => + Effect.gen(function* () { + const viewerUrl = `http://localhost:${LIVE_VIEWER_STATIC_PORT}/replay/?testId=${planId}`; + const response = yield* HttpClient.get(viewerUrl); + assert.notStrictEqual(response.status, 404); + const html = yield* response.text; + assert.include(html.toLowerCase(), ""); + }).pipe(run), + 60_000, + ); + + it( + "navigates to a website, and records a replay", + () => + Effect.gen(function* () { + const playwright = yield* Playwright; + const artifactsClient = yield* ArtifactClient; + + console.log( + `Live viewer: http://localhost:${LIVE_VIEWER_STATIC_PORT}/replay/?testId=${planId}`, + ); + yield* playwright.open({ + headless: true, + browserProfile: Option.none(), + initialNavigation: Option.some({ url: "https://www.skosh.dev/" }), + cdpUrl: Option.none(), + }); + + const snapshot = yield* playwright.snapshot({}); + assert.include(snapshot.tree, "Always learning."); + assert.include(snapshot.tree, "heading"); + assert.isAbove(snapshot.stats.totalRefs, 5); + + yield* Effect.sleep("15 seconds"); + + const events = yield* artifactsClient["artifact.StreamEvents"]({ + planId, + }).pipe( + Stream.filter((a): a is RrwebEvent => a._tag === "RrwebEvent"), + Stream.take(5), + Stream.runCollect, + ); + console.log("EVENTS:"); + console.log(events); + assert.isAbove(events.length, 2, "expected rrweb recording events"); + assert.isDefined(events[0].event, "rrweb event should have event data"); + }).pipe(run), + 60_000, + ); +}); diff --git a/packages/browser/tests/recorder.test.ts b/packages/browser/tests/recorder.test.ts deleted file mode 100644 index 733e7e985..000000000 --- a/packages/browser/tests/recorder.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { Effect } from "effect"; -import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; -import { describe, expect, it, afterEach } from "vite-plus/test"; -import type { eventWithTime } from "@rrweb/types"; -import { loadSession } from "../src/recorder"; - -const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer))); - -const TEMP_DIR_PREFIX = "recorder-test-"; - -const fakeEvent = (type: number, timestamp: number): eventWithTime => - ({ type, timestamp, data: {} }) as eventWithTime; - -describe("loadSession", () => { - let tempDir: string; - - afterEach(() => { - if (tempDir) rmSync(tempDir, { recursive: true, force: true }); - }); - - it("reads NDJSON and returns parsed events", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); - const sessionPath = join(tempDir, "session.ndjson"); - const events = [fakeEvent(2, 1000), fakeEvent(3, 2000), fakeEvent(3, 3000)]; - writeFileSync(sessionPath, events.map((event) => JSON.stringify(event)).join("\n") + "\n"); - - const loaded = await run(loadSession(sessionPath)); - - expect(loaded).toHaveLength(3); - expect(loaded[0]).toEqual(events[0]); - expect(loaded[2]).toEqual(events[2]); - }); - - it("fails with SessionLoadError for nonexistent file", async () => { - const result = await Effect.runPromiseExit( - loadSession("/nonexistent/path/session.ndjson").pipe(Effect.provide(NodeFileSystem.layer)), - ); - - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - const error = result.cause; - expect(String(error)).toContain("SessionLoadError"); - } - }); - - it("fails with SessionLoadError for invalid JSON line", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); - const sessionPath = join(tempDir, "bad.ndjson"); - writeFileSync( - sessionPath, - '{"type":2,"timestamp":1000}\nnot-json\n{"type":3,"timestamp":2000}\n', - ); - - const result = await Effect.runPromiseExit( - loadSession(sessionPath).pipe(Effect.provide(NodeFileSystem.layer)), - ); - - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - expect(String(result.cause)).toContain("SessionLoadError"); - expect(String(result.cause)).toContain("line 2"); - } - }); -}); diff --git a/packages/browser/tests/rrvideo.test.ts b/packages/browser/tests/rrvideo.test.ts index ab2d8d641..1e6cb3e69 100644 --- a/packages/browser/tests/rrvideo.test.ts +++ b/packages/browser/tests/rrvideo.test.ts @@ -1,5 +1,5 @@ -import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { tmpdir } from "node:os"; import { Effect } from "effect"; import { describe, expect, it, afterEach } from "vite-plus/test"; @@ -73,8 +73,8 @@ const makeMinimalSession = (durationMs = 500, width = 800, height = 600) => { }; const writeSessionFile = (dir: string, filename: string, events: unknown[]) => { - const sessionPath = join(dir, filename); - writeFileSync(sessionPath, JSON.stringify(events)); + const sessionPath = path.join(dir, filename); + fs.writeFileSync(sessionPath, JSON.stringify(events)); return sessionPath; }; @@ -103,39 +103,39 @@ describe("RrVideo", () => { let tempDir: string; afterEach(() => { - if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); }); describe("convert — success cases", () => { it("converts a minimal rrweb session to an MP4 file", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const events = makeMinimalSession(); const inputPath = writeSessionFile(tempDir, "session.json", events); - const outputPath = join(tempDir, "output.mp4"); + const outputPath = path.join(tempDir, "output.mp4"); const result = await runConvert({ inputPath, outputPath, speed: 8 }); expect(result).toBe(outputPath); - expect(existsSync(outputPath)).toBe(true); + expect(fs.existsSync(outputPath)).toBe(true); }, 30_000); it("creates output directory if it does not exist", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const events = makeMinimalSession(); const inputPath = writeSessionFile(tempDir, "session.json", events); - const outputPath = join(tempDir, "nested", "deep", "output.mp4"); + const outputPath = path.join(tempDir, "nested", "deep", "output.mp4"); const result = await runConvert({ inputPath, outputPath, speed: 8 }); expect(result).toBe(outputPath); - expect(existsSync(outputPath)).toBe(true); + expect(fs.existsSync(outputPath)).toBe(true); }, 30_000); it("respects custom resolution ratio", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const events = makeMinimalSession(500, 1280, 720); const inputPath = writeSessionFile(tempDir, "session.json", events); - const outputPath = join(tempDir, "low-res.mp4"); + const outputPath = path.join(tempDir, "low-res.mp4"); const result = await runConvert({ inputPath, @@ -145,14 +145,14 @@ describe("RrVideo", () => { }); expect(result).toBe(outputPath); - expect(existsSync(outputPath)).toBe(true); + expect(fs.existsSync(outputPath)).toBe(true); }, 30_000); it("calls onProgress during replay", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const events = makeMinimalSession(1000); const inputPath = writeSessionFile(tempDir, "session.json", events); - const outputPath = join(tempDir, "progress.mp4"); + const outputPath = path.join(tempDir, "progress.mp4"); const progressValues: number[] = []; await runConvert({ @@ -170,10 +170,10 @@ describe("RrVideo", () => { }, 30_000); it("handles skipInactive option", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const events = makeMinimalSession(); const inputPath = writeSessionFile(tempDir, "session.json", events); - const outputPath = join(tempDir, "skip-inactive.mp4"); + const outputPath = path.join(tempDir, "skip-inactive.mp4"); const result = await runConvert({ inputPath, @@ -183,14 +183,14 @@ describe("RrVideo", () => { }); expect(result).toBe(outputPath); - expect(existsSync(outputPath)).toBe(true); + expect(fs.existsSync(outputPath)).toBe(true); }, 30_000); }); describe("convert — error cases", () => { it("fails with RrVideoConvertError for nonexistent input file", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); - const outputPath = join(tempDir, "output.mp4"); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); + const outputPath = path.join(tempDir, "output.mp4"); const result = await runConvertExit({ inputPath: "/nonexistent/path/session.json", @@ -205,10 +205,10 @@ describe("RrVideo", () => { }); it("fails with RrVideoConvertError for invalid JSON", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); - const inputPath = join(tempDir, "bad.json"); - writeFileSync(inputPath, "not valid json {{{"); - const outputPath = join(tempDir, "output.mp4"); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); + const inputPath = path.join(tempDir, "bad.json"); + fs.writeFileSync(inputPath, "not valid json {{{"); + const outputPath = path.join(tempDir, "output.mp4"); const result = await runConvertExit({ inputPath, outputPath }); @@ -220,10 +220,10 @@ describe("RrVideo", () => { }); it("fails with RrVideoConvertError when JSON is not an array", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); - const inputPath = join(tempDir, "object.json"); - writeFileSync(inputPath, JSON.stringify({ type: 4, timestamp: 1000 })); - const outputPath = join(tempDir, "output.mp4"); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); + const inputPath = path.join(tempDir, "object.json"); + fs.writeFileSync(inputPath, JSON.stringify({ type: 4, timestamp: 1000 })); + const outputPath = path.join(tempDir, "output.mp4"); const result = await runConvertExit({ inputPath, outputPath }); @@ -235,13 +235,13 @@ describe("RrVideo", () => { }); it("fails with RrVideoConvertError when events have invalid shape", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const events = [ { type: 4, timestamp: 1000, data: { width: 800, height: 600 } }, { notType: "bad", notTimestamp: "bad" }, ]; const inputPath = writeSessionFile(tempDir, "bad-shape.json", events); - const outputPath = join(tempDir, "output.mp4"); + const outputPath = path.join(tempDir, "output.mp4"); const result = await runConvertExit({ inputPath, outputPath }); @@ -253,9 +253,9 @@ describe("RrVideo", () => { }); it("fails with RrVideoConvertError for empty events array", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const inputPath = writeSessionFile(tempDir, "empty.json", []); - const outputPath = join(tempDir, "output.mp4"); + const outputPath = path.join(tempDir, "output.mp4"); const result = await runConvertExit({ inputPath, outputPath }); @@ -267,13 +267,13 @@ describe("RrVideo", () => { }); it("fails with RrVideoConvertError when no meta events provide viewport", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const events = [ { type: 2, timestamp: 1000, data: {} }, { type: 3, timestamp: 2000, data: {} }, ]; const inputPath = writeSessionFile(tempDir, "no-viewport.json", events); - const outputPath = join(tempDir, "output.mp4"); + const outputPath = path.join(tempDir, "output.mp4"); const result = await runConvertExit({ inputPath, outputPath }); @@ -285,13 +285,13 @@ describe("RrVideo", () => { }); it("fails with RrVideoConvertError when viewport has zero dimensions", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const events = [ { type: 4, timestamp: 1000, data: { width: 0, height: 0 } }, { type: 2, timestamp: 2000, data: {} }, ]; const inputPath = writeSessionFile(tempDir, "zero-viewport.json", events); - const outputPath = join(tempDir, "output.mp4"); + const outputPath = path.join(tempDir, "output.mp4"); const result = await runConvertExit({ inputPath, outputPath }); @@ -304,10 +304,10 @@ describe("RrVideo", () => { describe("convert — cleans up temp resources", () => { it("does not leave temp directories after successful conversion", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const events = makeMinimalSession(); const inputPath = writeSessionFile(tempDir, "session.json", events); - const outputPath = join(tempDir, "output.mp4"); + const outputPath = path.join(tempDir, "output.mp4"); await runConvert({ inputPath, outputPath, speed: 8 }); @@ -317,13 +317,13 @@ describe("RrVideo", () => { }, 30_000); it("cleans up temp resources even when conversion fails", async () => { - tempDir = mkdtempSync(join(tmpdir(), TEMP_DIR_PREFIX)); + tempDir = fs.mkdtempSync(path.join(tmpdir(), TEMP_DIR_PREFIX)); const inputPath = writeSessionFile(tempDir, "empty.json", []); - const outputPath = join(tempDir, "output.mp4"); + const outputPath = path.join(tempDir, "output.mp4"); await runConvertExit({ inputPath, outputPath }); - expect(existsSync(outputPath)).toBe(false); + expect(fs.existsSync(outputPath)).toBe(false); }); }); diff --git a/packages/browser/tests/snapshot.test.ts b/packages/browser/tests/snapshot.test.ts index c20fea1fa..4e5b5737e 100644 --- a/packages/browser/tests/snapshot.test.ts +++ b/packages/browser/tests/snapshot.test.ts @@ -1,41 +1,52 @@ -import { Effect } from "effect"; -import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; -import { chromium } from "playwright"; -import type { Browser as PlaywrightBrowser, Page } from "playwright"; -import { Browser } from "../src/browser"; - -const run = (effect: Effect.Effect) => Effect.runPromise(effect); +import { Effect, Layer, Option } from "effect"; +import { describe, it, expect } from "vite-plus/test"; +import { Playwright } from "../src/playwright"; +import { Artifacts } from "../src/artifacts"; +import type { SnapshotOptions } from "../src/types"; + +const playwrightLayer = Playwright.layer.pipe(Layer.provide(Artifacts.layerTest(() => {}))); + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(playwrightLayer))); + +const withPage = ( + content: string, + fn: (pw: typeof Playwright.Service) => Effect.Effect, +) => + run( + Effect.gen(function* () { + const pw = yield* Playwright; + yield* pw.open({ + headless: true, + browserProfile: Option.none(), + initialNavigation: Option.none(), + cdpUrl: Option.none(), + }); + const page = yield* pw.getPage; + yield* Effect.tryPromise(() => page.setContent(content)); + return yield* fn(pw); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const pw = yield* Playwright; + if (pw.hasSession()) yield* pw.close(); + }), + ), + ), + ); -const snapshotPage = (page: Page, options = {}) => - Effect.gen(function* () { - const browser = yield* Browser; - return yield* browser.snapshot(page, options); - }).pipe(Effect.provide(Browser.layer)); +const snapshotContent = (content: string, options: SnapshotOptions = {}) => + withPage(content, (pw) => pw.snapshot(options)); describe("snapshot", () => { - let playwrightBrowser: PlaywrightBrowser; - let page: Page; - - beforeAll(async () => { - playwrightBrowser = await chromium.launch({ headless: true }); - const context = await playwrightBrowser.newContext(); - page = await context.newPage(); - }); - - afterAll(async () => { - await playwrightBrowser.close(); - }); - describe("tree and refs", () => { it("should return a tree with refs", async () => { - await page.setContent(` + const result = await snapshotContent(`

    Hello World

    About `); - - const result = await run(snapshotPage(page)); expect(result.tree).toContain("heading"); expect(result.tree).toContain("Hello World"); expect(result.tree).toContain("[ref=e1]"); @@ -44,41 +55,35 @@ describe("snapshot", () => { }); it("should assign sequential ref ids", async () => { - await page.setContent(` + const result = await snapshotContent(` `); - - const result = await run(snapshotPage(page)); expect(result.refs.e1).toBeDefined(); expect(result.refs.e2).toBeDefined(); expect(result.refs.e3).toBeDefined(); }); it("should store role and name in refs", async () => { - await page.setContent(` + const result = await snapshotContent(` `); - - const result = await run(snapshotPage(page)); const buttonRef = Object.values(result.refs).find((entry) => entry.name === "Submit"); expect(buttonRef).toBeDefined(); expect(buttonRef?.role).toBe("button"); }); it("should handle empty name", async () => { - await page.setContent(` + const result = await snapshotContent(` `); - - const result = await run(snapshotPage(page)); const buttonRef = Object.values(result.refs).find((entry) => entry.role === "button"); expect(buttonRef).toBeDefined(); expect(buttonRef?.name).toBe(""); @@ -87,15 +92,13 @@ describe("snapshot", () => { describe("nth disambiguation", () => { it("should set nth on duplicate role+name entries", async () => { - await page.setContent(` + const result = await snapshotContent(` `); - - const result = await run(snapshotPage(page)); const okButtons = Object.values(result.refs).filter( (entry) => entry.role === "button" && entry.name === "OK", ); @@ -105,14 +108,12 @@ describe("snapshot", () => { }); it("should not set nth on unique role+name entries", async () => { - await page.setContent(` + const result = await snapshotContent(` `); - - const result = await run(snapshotPage(page)); for (const entry of Object.values(result.refs)) { expect(entry.nth).toBeUndefined(); } @@ -121,161 +122,156 @@ describe("snapshot", () => { describe("locator", () => { it("should resolve ref to a working locator", async () => { - await page.setContent(` - -

    Title

    - - - `); - - const result = await run(snapshotPage(page)); - const buttonRefKey = Object.keys(result.refs).find( - (key) => result.refs[key].name === "Click Me", + await withPage(`

    Title

    `, (pw) => + Effect.gen(function* () { + const result = yield* pw.snapshot({}); + const buttonRefKey = Object.keys(result.refs).find( + (key) => result.refs[key].name === "Click Me", + ); + expect(buttonRefKey).toBeDefined(); + const locator = yield* result.locator(buttonRefKey!); + const text = yield* Effect.tryPromise(() => locator.textContent()); + expect(text).toBe("Click Me"); + }), ); - expect(buttonRefKey).toBeDefined(); - - const locator = await run(result.locator(buttonRefKey!)); - const text = await locator.textContent(); - expect(text).toBe("Click Me"); }); it("should fail on unknown ref with available refs", async () => { - await page.setContent(` - - - - `); - const result = await run(snapshotPage(page)); - await expect(run(result.locator("nonexistent"))).rejects.toThrow("available refs: e1"); + await expect( + withPage(``, (pw) => + Effect.gen(function* () { + const result = yield* pw.snapshot({}); + yield* result.locator("nonexistent"); + }), + ), + ).rejects.toThrow("available refs: e1"); }); it("should fail on unknown ref with empty page hint", async () => { - await page.setContent(""); - const result = await run(snapshotPage(page)); - await expect(run(result.locator("e1"))).rejects.toThrow("no refs available"); + await expect( + withPage("", (pw) => + Effect.gen(function* () { + const result = yield* pw.snapshot({}); + yield* result.locator("e1"); + }), + ), + ).rejects.toThrow("no refs available"); }); it("should click the correct element via ref", async () => { - await page.setContent(` - - - - `); - - const result = await run(snapshotPage(page)); - const buttonRefKey = Object.keys(result.refs).find( - (key) => result.refs[key].name === "Click Me", + await withPage( + ``, + (pw) => + Effect.gen(function* () { + const result = yield* pw.snapshot({}); + const buttonRefKey = Object.keys(result.refs).find( + (key) => result.refs[key].name === "Click Me", + ); + const locator = yield* result.locator(buttonRefKey!); + yield* Effect.tryPromise(() => locator.click()); + const page = yield* pw.getPage; + expect(yield* Effect.tryPromise(() => page.title())).toBe("clicked"); + }), ); - const locator = await run(result.locator(buttonRefKey!)); - await locator.click(); - expect(await page.title()).toBe("clicked"); }); it("should click the correct unnamed button among named buttons", async () => { - await page.setContent(` - - - - - - `); - - const result = await run(snapshotPage(page)); - const unnamedButtons = Object.entries(result.refs).filter( - ([, entry]) => entry.role === "button" && !entry.name, + await withPage( + ``, + (pw) => + Effect.gen(function* () { + const result = yield* pw.snapshot({}); + const unnamedButtons = Object.entries(result.refs).filter( + ([, entry]) => entry.role === "button" && !entry.name, + ); + expect(unnamedButtons.length).toBe(1); + const [refKey] = unnamedButtons[0]; + const locator = yield* result.locator(refKey); + yield* Effect.tryPromise(() => locator.click()); + const page = yield* pw.getPage; + expect(yield* Effect.tryPromise(() => page.title())).toBe("unnamed"); + }), ); - expect(unnamedButtons.length).toBe(1); - - const [refKey] = unnamedButtons[0]; - const locator = await run(result.locator(refKey)); - await locator.click(); - expect(await page.title()).toBe("unnamed"); }); it("should click the correct duplicate button via nth", async () => { - await page.setContent(` - - - - - `); - - const result = await run(snapshotPage(page)); - const okButtons = Object.entries(result.refs).filter( - ([, entry]) => entry.role === "button" && entry.name === "OK", + await withPage( + ``, + (pw) => + Effect.gen(function* () { + const result = yield* pw.snapshot({}); + const okButtons = Object.entries(result.refs).filter( + ([, entry]) => entry.role === "button" && entry.name === "OK", + ); + expect(okButtons.length).toBe(2); + const locator = yield* result.locator(okButtons[1][0]); + yield* Effect.tryPromise(() => locator.click()); + const page = yield* pw.getPage; + expect(yield* Effect.tryPromise(() => page.title())).toBe("second"); + }), ); - expect(okButtons.length).toBe(2); - - const locator = await run(result.locator(okButtons[1][0])); - await locator.click(); - expect(await page.title()).toBe("second"); }); it("should fill an input via ref", async () => { - await page.setContent(` - - - - - `); - - const result = await run(snapshotPage(page)); - const inputRefKey = Object.keys(result.refs).find( - (key) => result.refs[key].role === "textbox", + await withPage( + ``, + (pw) => + Effect.gen(function* () { + const result = yield* pw.snapshot({}); + const inputRefKey = Object.keys(result.refs).find( + (key) => result.refs[key].role === "textbox", + ); + expect(inputRefKey).toBeDefined(); + const locator = yield* result.locator(inputRefKey!); + yield* Effect.tryPromise(() => locator.fill("test@example.com")); + const page = yield* pw.getPage; + const value = yield* Effect.tryPromise(() => page.locator("#email").inputValue()); + expect(value).toBe("test@example.com"); + }), ); - expect(inputRefKey).toBeDefined(); - - const locator = await run(result.locator(inputRefKey!)); - await locator.fill("test@example.com"); - const value = await page.locator("#email").inputValue(); - expect(value).toBe("test@example.com"); }); it("should select an option via ref", async () => { - await page.setContent(` - - - - - `); - - const result = await run(snapshotPage(page)); - const selectRefKey = Object.keys(result.refs).find( - (key) => result.refs[key].role === "combobox", + await withPage( + ``, + (pw) => + Effect.gen(function* () { + const result = yield* pw.snapshot({}); + const selectRefKey = Object.keys(result.refs).find( + (key) => result.refs[key].role === "combobox", + ); + expect(selectRefKey).toBeDefined(); + const locator = yield* result.locator(selectRefKey!); + yield* Effect.tryPromise(() => locator.selectOption("blue")); + const page = yield* pw.getPage; + const value = yield* Effect.tryPromise(() => page.locator("#color").inputValue()); + expect(value).toBe("blue"); + }), ); - expect(selectRefKey).toBeDefined(); - - const locator = await run(result.locator(selectRefKey!)); - await locator.selectOption("blue"); - const value = await page.locator("#color").inputValue(); - expect(value).toBe("blue"); }); }); describe("timeout", () => { it("should accept a custom timeout", async () => { - await page.setContent("

    Hello

    "); - const result = await run(snapshotPage(page, { timeout: 5000 })); + const result = await snapshotContent("

    Hello

    ", { + timeout: 5000, + }); expect(result.tree).toContain("paragraph"); }); }); describe("interactive filter", () => { it("should only include interactive elements", async () => { - await page.setContent(` - + const result = await snapshotContent( + `

    Title

    Description

    Link - - `); - - const result = await run(snapshotPage(page, { interactive: true })); + `, + { interactive: true }, + ); const roles = Object.values(result.refs).map((entry) => entry.role); expect(roles).toContain("button"); expect(roles).toContain("link"); @@ -285,27 +281,19 @@ describe("snapshot", () => { }); it("should return no interactive elements message for static page", async () => { - await page.setContent(` - -

    Title

    -

    Just text

    - - `); - - const result = await run(snapshotPage(page, { interactive: true })); + const result = await snapshotContent( + `

    Title

    Just text

    `, + { interactive: true }, + ); expect(result.tree).toBe("(no interactive elements)"); expect(Object.keys(result.refs).length).toBe(0); }); it("should exclude non-interactive tree lines", async () => { - await page.setContent(` - -

    Title

    - - - `); - - const result = await run(snapshotPage(page, { interactive: true })); + const result = await snapshotContent( + `

    Title

    `, + { interactive: true }, + ); expect(result.tree).not.toContain("heading"); expect(result.tree).toContain("button"); }); @@ -313,34 +301,19 @@ describe("snapshot", () => { describe("compact filter", () => { it("should remove empty structural nodes without refs", async () => { - await page.setContent(` - -
    -
    - -
    -
    - - `); - - const full = await run(snapshotPage(page)); - const compacted = await run(snapshotPage(page, { compact: true })); + const content = `
    `; + const full = await snapshotContent(content); + const compacted = await snapshotContent(content, { compact: true }); expect(compacted.tree.split("\n").length).toBeLessThanOrEqual(full.tree.split("\n").length); expect(compacted.tree).toContain("button"); expect(compacted.tree).toContain("[ref="); }); it("should keep structural parents of ref-bearing children", async () => { - await page.setContent(` - - - - `); - - const result = await run(snapshotPage(page, { compact: true })); + const result = await snapshotContent( + ``, + { compact: true }, + ); expect(result.tree).toContain("navigation"); expect(result.tree).toContain("link"); }); @@ -348,56 +321,33 @@ describe("snapshot", () => { describe("maxDepth filter", () => { it("should limit tree depth", async () => { - await page.setContent(` - - - - `); - - const shallow = await run(snapshotPage(page, { maxDepth: 1 })); - const deep = await run(snapshotPage(page)); + const content = ``; + const shallow = await snapshotContent(content, { maxDepth: 1 }); + const deep = await snapshotContent(content); expect(shallow.tree.split("\n").length).toBeLessThan(deep.tree.split("\n").length); }); it("should return top-level elements only at depth 0", async () => { - await page.setContent(` - -

    Title

    - - - `); - - const result = await run(snapshotPage(page, { maxDepth: 0 })); + const result = await snapshotContent( + `

    Title

    `, + { maxDepth: 0 }, + ); for (const line of result.tree.split("\n")) { - if (line.trim()) { - expect(line).toMatch(/^- /); - } + if (line.trim()) expect(line).toMatch(/^- /); } }); }); describe("combined filters", () => { it("should apply interactive and compact together", async () => { - await page.setContent(` - + const result = await snapshotContent( + `

    Title

    -
    -
    - -
    -
    +

    Footer text

    - - `); - - const result = await run(snapshotPage(page, { interactive: true, compact: true })); + `, + { interactive: true, compact: true }, + ); expect(result.tree).toContain("button"); expect(result.tree).not.toContain("heading"); expect(result.tree).not.toContain("paragraph"); @@ -405,69 +355,42 @@ describe("snapshot", () => { }); it("should apply interactive and maxDepth together", async () => { - await page.setContent(` - - + const result = await snapshotContent( + ` + - - `); - - const result = await run(snapshotPage(page, { interactive: true, maxDepth: 0 })); + `, + { interactive: true, maxDepth: 0 }, + ); const roles = Object.values(result.refs).map((entry) => entry.role); expect(roles).toContain("button"); expect(roles).not.toContain("link"); }); it("should apply compact and maxDepth together", async () => { - await page.setContent(` - - -
    -
    -

    Deep text

    -
    -
    - - `); - - const result = await run(snapshotPage(page, { compact: true, maxDepth: 2 })); + const result = await snapshotContent( + ` + +

    Deep text

    + `, + { compact: true, maxDepth: 2 }, + ); expect(result.tree).toContain("navigation"); }); it("should apply all three filters together", async () => { - await page.setContent(` - + const result = await snapshotContent( + `

    Title

    - +

    Content

    - - `); - - const result = await run( - snapshotPage(page, { - interactive: true, - compact: true, - maxDepth: 1, - }), + `, + { interactive: true, compact: true, maxDepth: 1 }, ); expect(result.tree).not.toContain("heading"); expect(result.tree).not.toContain("paragraph"); @@ -476,82 +399,81 @@ describe("snapshot", () => { describe("diverse interactive roles", () => { it("should handle radio buttons", async () => { - await page.setContent( + const result = await snapshotContent( `
    Size
    `, + { interactive: true }, ); - const result = await run(snapshotPage(page, { interactive: true })); expect(Object.values(result.refs).map((entry) => entry.role)).toContain("radio"); }); it("should handle checkboxes", async () => { - await page.setContent( + const result = await snapshotContent( ``, + { interactive: true }, ); - const result = await run(snapshotPage(page, { interactive: true })); expect(Object.values(result.refs).map((entry) => entry.role)).toContain("checkbox"); }); it("should handle sliders", async () => { - await page.setContent( + const result = await snapshotContent( ``, + { interactive: true }, ); - const result = await run(snapshotPage(page, { interactive: true })); expect(Object.values(result.refs).map((entry) => entry.role)).toContain("slider"); }); it("should handle switch role", async () => { - await page.setContent( + const result = await snapshotContent( ``, + { interactive: true }, ); - const result = await run(snapshotPage(page, { interactive: true })); expect(Object.values(result.refs).map((entry) => entry.role)).toContain("switch"); }); it("should handle tab role", async () => { - await page.setContent( + const result = await snapshotContent( `
    `, + { interactive: true }, ); - const result = await run(snapshotPage(page, { interactive: true })); expect(Object.values(result.refs).filter((entry) => entry.role === "tab").length).toBe(2); }); it("should handle searchbox", async () => { - await page.setContent( + const result = await snapshotContent( ``, + { interactive: true }, ); - const result = await run(snapshotPage(page, { interactive: true })); expect(Object.values(result.refs).map((entry) => entry.role)).toContain("searchbox"); }); it("should handle spinbutton", async () => { - await page.setContent( + const result = await snapshotContent( ``, + { interactive: true }, ); - const result = await run(snapshotPage(page, { interactive: true })); expect(Object.values(result.refs).map((entry) => entry.role)).toContain("spinbutton"); }); }); describe("edge cases", () => { it("should handle a completely empty body", async () => { - await page.setContent(""); - const result = await run(snapshotPage(page)); + const result = await snapshotContent(""); expect(Object.keys(result.refs).length).toBe(0); }); it("should handle elements with special characters in names", async () => { - await page.setContent(``); - const result = await run(snapshotPage(page)); + const result = await snapshotContent( + ``, + ); const entry = Object.values(result.refs).find((ref) => ref.role === "button"); expect(entry).toBeDefined(); expect(entry?.name).toContain("&"); }); it("should handle multiple nested forms", async () => { - await page.setContent( + const result = await snapshotContent( `
    `, ); - const result = await run(snapshotPage(page)); expect(Object.values(result.refs).filter((entry) => entry.role === "button").length).toBe(2); expect( Object.values(result.refs).filter( @@ -561,10 +483,9 @@ describe("snapshot", () => { }); it("should handle aria-label overriding visible text", async () => { - await page.setContent( + const result = await snapshotContent( ``, ); - const result = await run(snapshotPage(page)); const entry = Object.values(result.refs).find((ref) => ref.role === "button"); expect(entry?.name).toBe("Close dialog"); }); @@ -574,42 +495,42 @@ describe("snapshot", () => { { length: 50 }, (_, index) => ``, ).join(""); - await page.setContent(`${items}`); - const result = await run(snapshotPage(page)); + const result = await snapshotContent(`${items}`); expect(Object.keys(result.refs).length).toBe(50); expect(result.refs.e1).toBeDefined(); expect(result.refs.e50).toBeDefined(); }); it("should handle default options when none are provided", async () => { - await page.setContent(`

    Hello

    `); - const result = await run(snapshotPage(page)); + const result = await snapshotContent(`

    Hello

    `); expect(result.tree).toBeTruthy(); expect(result.refs).toBeDefined(); expect(typeof result.locator).toBe("function"); }); it("should exclude text role from refs", async () => { - await page.setContent( + const result = await snapshotContent( `

    Some plain text

    `, ); - const result = await run(snapshotPage(page)); expect(Object.values(result.refs).map((entry) => entry.role)).not.toContain("text"); }); it("should handle nested interactive elements inside structural containers", async () => { - await page.setContent( + const result = await snapshotContent( ``, + { interactive: true }, ); - const result = await run(snapshotPage(page, { interactive: true })); expect(Object.values(result.refs).filter((entry) => entry.role === "link").length).toBe(3); }); it("should produce consistent refs for the same content", async () => { - await page.setContent(``); - const first = await run(snapshotPage(page)); - const second = await run(snapshotPage(page)); - expect(first.refs).toEqual(second.refs); + await withPage(``, (pw) => + Effect.gen(function* () { + const first = yield* pw.snapshot({}); + const second = yield* pw.snapshot({}); + expect(first.refs).toEqual(second.refs); + }), + ); }); }); }); diff --git a/packages/browser/tests/viewport-aware-snapshot.test.ts b/packages/browser/tests/viewport-aware-snapshot.test.ts deleted file mode 100644 index 3066dfa54..000000000 --- a/packages/browser/tests/viewport-aware-snapshot.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { Effect } from "effect"; -import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; -import { chromium } from "playwright"; -import type { Browser as PlaywrightBrowser, Page } from "playwright"; -import { Browser } from "../src/browser"; -import { RUNTIME_SCRIPT } from "../src/generated/runtime-script"; - -const run = (effect: Effect.Effect) => Effect.runPromise(effect); - -const snapshotPage = (page: Page, options = {}) => - Effect.gen(function* () { - const browser = yield* Browser; - return yield* browser.snapshot(page, options); - }).pipe(Effect.provide(Browser.layer)); - -const generateItems = (count: number) => - Array.from({ length: count }, (_, index) => ``).join("\n"); - -const SCROLLABLE_PAGE = ` - -

    Page Title

    -
    - ${generateItems(30)} -
    -`; - -describe("viewport-aware snapshot", () => { - let playwrightBrowser: PlaywrightBrowser; - let page: Page; - - beforeAll(async () => { - playwrightBrowser = await chromium.launch({ headless: true }); - const context = await playwrightBrowser.newContext(); - await context.addInitScript(RUNTIME_SCRIPT); - page = await context.newPage(); - }); - - afterAll(async () => { - await playwrightBrowser.close(); - }); - - describe("scroll container filtering", () => { - it("should hide off-viewport items in scrollable containers", async () => { - await page.setContent(SCROLLABLE_PAGE); - const result = await run(snapshotPage(page, { viewportAware: true })); - - const itemLines = result.tree.split("\n").filter((line) => line.includes("Item ")); - expect(itemLines.length).toBeLessThan(30); - }); - - it("should include hidden-content marker notes", async () => { - await page.setContent(SCROLLABLE_PAGE); - const result = await run(snapshotPage(page, { viewportAware: true })); - - const hasHiddenBelow = result.tree.includes("items hidden below"); - expect(hasHiddenBelow).toBe(true); - }); - - it("should show all items when viewportAware is false", async () => { - await page.setContent(SCROLLABLE_PAGE); - const result = await run(snapshotPage(page, { viewportAware: false })); - - const itemLines = result.tree.split("\n").filter((line) => line.includes("Item ")); - expect(itemLines.length).toBe(30); - expect(result.tree).not.toContain("items hidden"); - }); - - it("should populate totalNodes and visibleNodes in stats when filtering applies", async () => { - await page.setContent(SCROLLABLE_PAGE); - const result = await run(snapshotPage(page, { viewportAware: true })); - - expect(result.stats.totalNodes).toBeDefined(); - expect(result.stats.visibleNodes).toBeDefined(); - expect(result.stats.totalNodes).toBeGreaterThan(result.stats.visibleNodes!); - expect(result.stats.visibleNodes).toBe(result.stats.lines); - }); - - it("should not set totalNodes/visibleNodes when viewportAware is false", async () => { - await page.setContent(SCROLLABLE_PAGE); - const result = await run(snapshotPage(page, { viewportAware: false })); - - expect(result.stats.totalNodes).toBeUndefined(); - expect(result.stats.visibleNodes).toBeUndefined(); - }); - }); - - describe("non-scrollable pages", () => { - it("should not alter tree for pages without scrollable containers", async () => { - await page.setContent(` - -

    Hello

    - - - `); - - const withViewport = await run(snapshotPage(page, { viewportAware: true })); - const withoutViewport = await run(snapshotPage(page, { viewportAware: false })); - - expect(withViewport.tree).toBe(withoutViewport.tree); - expect(withViewport.tree).not.toContain("items hidden"); - }); - - it("should not set totalNodes/visibleNodes when no filtering occurs", async () => { - await page.setContent(` - - - - - `); - - const result = await run(snapshotPage(page, { viewportAware: true })); - expect(result.stats.totalNodes).toBeUndefined(); - expect(result.stats.visibleNodes).toBeUndefined(); - }); - }); - - describe("scroll position awareness", () => { - it("should reflect current scroll position in visible items", async () => { - await page.setContent(SCROLLABLE_PAGE); - - const beforeScroll = await run(snapshotPage(page, { viewportAware: true })); - expect(beforeScroll.tree).toContain("Item 1"); - - await page.evaluate(() => { - const scrollContainer = document.querySelector('[aria-label="Item List"]'); - if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight; - }); - - const afterScroll = await run(snapshotPage(page, { viewportAware: true })); - expect(afterScroll.tree).toContain("Item 30"); - expect(afterScroll.tree).toContain("items hidden above"); - }); - }); - - describe("DOM restoration", () => { - it("should not leave aria-hidden attributes on elements after snapshot", async () => { - await page.setContent(SCROLLABLE_PAGE); - await run(snapshotPage(page, { viewportAware: true })); - - const hiddenCount = await page.evaluate( - () => document.querySelectorAll("[data-expect-scroll-hidden]").length, - ); - expect(hiddenCount).toBe(0); - }); - - it("should not leave marker elements in the DOM after snapshot", async () => { - await page.setContent(SCROLLABLE_PAGE); - await run(snapshotPage(page, { viewportAware: true })); - - const markerCount = await page.evaluate( - () => document.querySelectorAll("[data-expect-scroll-marker]").length, - ); - expect(markerCount).toBe(0); - }); - - it("should preserve pre-existing aria-hidden attributes", async () => { - await page.setContent(` - -
    - - - - - - - - - - - -
    - - `); - - await run(snapshotPage(page, { viewportAware: true })); - - const decorativeHidden = await page.evaluate( - () => document.querySelector('button[aria-hidden="true"]')?.textContent, - ); - expect(decorativeHidden).toBe("Decorative 1"); - }); - - it("should restore DOM even when ariaSnapshot fails", async () => { - await page.setContent(SCROLLABLE_PAGE); - - await run( - snapshotPage(page, { viewportAware: true, selector: "nonexistent", timeout: 100 }), - ).catch(() => {}); - - const hiddenCount = await page.evaluate( - () => document.querySelectorAll("[data-expect-scroll-hidden]").length, - ); - const markerCount = await page.evaluate( - () => document.querySelectorAll("[data-expect-scroll-marker]").length, - ); - expect(hiddenCount).toBe(0); - expect(markerCount).toBe(0); - }); - }); - - describe("small scroll containers", () => { - it("should not filter containers with fewer than 5 children", async () => { - await page.setContent(` - -
    - - - -
    - - `); - - const withViewport = await run(snapshotPage(page, { viewportAware: true })); - const withoutViewport = await run(snapshotPage(page, { viewportAware: false })); - - expect(withViewport.tree).toBe(withoutViewport.tree); - }); - }); - - describe("nested scroll containers", () => { - it("should filter both inner and outer scroll containers independently", async () => { - const innerItems = Array.from( - { length: 15 }, - (_, index) => ``, - ).join("\n"); - const outerItems = Array.from( - { length: 10 }, - (_, index) => ``, - ).join("\n"); - - await page.setContent(` - -
    - ${outerItems} -
    - ${innerItems} -
    -
    - - `); - - const result = await run(snapshotPage(page, { viewportAware: true })); - const fullResult = await run(snapshotPage(page, { viewportAware: false })); - - const visibleLines = result.tree.split("\n").length; - const fullLines = fullResult.tree.split("\n").length; - expect(visibleLines).toBeLessThan(fullLines); - }); - }); - - describe("ref assignment with viewport-aware filtering", () => { - it("should assign refs only to visible elements", async () => { - await page.setContent(SCROLLABLE_PAGE); - const result = await run(snapshotPage(page, { viewportAware: true })); - - const refCount = Object.keys(result.refs).length; - expect(refCount).toBeLessThan(31); - expect(refCount).toBeGreaterThan(0); - }); - - it("should resolve refs to correct visible elements", async () => { - await page.setContent(SCROLLABLE_PAGE); - const result = await run(snapshotPage(page, { viewportAware: true })); - - const firstItemRef = Object.entries(result.refs).find( - ([, entry]) => entry.role === "button" && entry.name.startsWith("Item"), - ); - expect(firstItemRef).toBeDefined(); - - const locator = await run(result.locator(firstItemRef![0])); - const text = await locator.textContent(); - expect(text).toContain("Item"); - }); - }); -}); diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json index 57bf531c0..74ca7f8f2 100644 --- a/packages/browser/tsconfig.json +++ b/packages/browser/tsconfig.json @@ -2,8 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src", "types": ["node"] }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/packages/cookies/package.json b/packages/cookies/package.json index 86788dfe1..471cb0e1b 100644 --- a/packages/cookies/package.json +++ b/packages/cookies/package.json @@ -11,12 +11,13 @@ "format": "vp fmt", "format:check": "vp fmt --check", "check": "vp check", - "test": "vp test run", + "test": "vp test run --no-file-parallelism", "test:watch": "vp test", "typecheck": "tsgo --noEmit" }, "dependencies": { "@effect/platform-node": "4.0.0-beta.35", + "@expect/shared": "workspace:*", "default-browser": "^5.5.0", "effect": "4.0.0-beta.35", "get-port": "^7.1.0", diff --git a/packages/cookies/src/browser-config.ts b/packages/cookies/src/browser-config.ts index 8850e178a..d6d54fe4e 100644 --- a/packages/cookies/src/browser-config.ts +++ b/packages/cookies/src/browser-config.ts @@ -1,4 +1,4 @@ -import type { BrowserKey, ChromiumBrowserKey } from "./types"; +import type { BrowserKey, ChromiumBrowserKey } from "@expect/shared/models"; interface PlatformPaths { readonly darwin: string; diff --git a/packages/cookies/src/browser-detector.ts b/packages/cookies/src/browser-detector.ts index 5e53e0f90..40b6af025 100644 --- a/packages/cookies/src/browser-detector.ts +++ b/packages/cookies/src/browser-detector.ts @@ -1,8 +1,20 @@ -import { Effect, identity, Layer, Option, ServiceMap, Array as Arr } from "effect"; +import { Effect, identity, Layer, Option, Schema, ServiceMap, Array as Arr } from "effect"; import getDefaultBrowser from "default-browser"; import { configByBundleId, configByDesktopFile } from "./browser-config"; import { ListBrowsersError } from "./errors"; -import type { Browser } from "./types"; +import type { Browser } from "@expect/shared/models"; + +export class BrowserProfileNotFoundError extends Schema.ErrorClass( + "BrowserProfileNotFoundError", +)({ + _tag: Schema.tag("BrowserProfileNotFoundError"), + profileId: Schema.String, + availableProfileIds: Schema.Array(Schema.String), +}) { + message = `Browser profile not found: ${ + this.profileId + }. Available profiles: ${this.availableProfileIds.join(", ")}`; +} export class Browsers extends ServiceMap.Service()("@cookies/Browsers", { make: Effect.gen(function* () { @@ -15,7 +27,30 @@ export class Browsers extends ServiceMap.Service()("@cookies/Browsers" const list = Effect.forEach(sources, identity, { concurrency: "unbounded", - }).pipe(Effect.map(Arr.flatten), Effect.withSpan("Browsers.list")); + }).pipe( + Effect.map(Arr.flatten), + /** @note(rasmus): we filter out System Profile, because usually this one doesn't contain any cookies and arent actually used by users. */ + Effect.map( + Arr.filter((browser) => + browser._tag === "ChromiumBrowser" && browser.profileName === "System Profile" + ? false + : true, + ), + ), + Effect.withSpan("Browsers.list"), + ); + + const findById = Effect.fn("Browsers.findById")(function* (profileId: string) { + const browsers = yield* list; + const match = Arr.findFirst(browsers, (browser) => browser.id === profileId); + if (Option.isNone(match)) { + return yield* new BrowserProfileNotFoundError({ + profileId, + availableProfileIds: browsers.map((b) => b.id), + }); + } + return match.value; + }); const defaultBrowser = Effect.fn("Browsers.defaultBrowser")(function* () { const result = yield* Effect.tryPromise({ @@ -23,9 +58,9 @@ export class Browsers extends ServiceMap.Service()("@cookies/Browsers" catch: (cause) => new ListBrowsersError({ cause: String(cause) }), }).pipe( Effect.catchTag("ListBrowsersError", (error) => - Effect.logWarning("Default browser detection failed", { cause: error.cause }).pipe( - Effect.as(undefined), - ), + Effect.logWarning("Default browser detection failed", { + cause: error.cause, + }).pipe(Effect.as(undefined)), ), ); @@ -47,7 +82,7 @@ export class Browsers extends ServiceMap.Service()("@cookies/Browsers" ); }); - return { register, list, defaultBrowser }; + return { register, list, findById, defaultBrowser }; }), }) { static layer = Layer.effect(this, this.make); diff --git a/packages/cookies/src/cdp-client.ts b/packages/cookies/src/cdp-client.ts index ffa1bc63a..dffd151f8 100644 --- a/packages/cookies/src/cdp-client.ts +++ b/packages/cookies/src/cdp-client.ts @@ -1,5 +1,5 @@ import os from "node:os"; -import path from "node:path"; +import * as path from "node:path"; import { Effect, Layer, @@ -21,7 +21,7 @@ import { ExtractionError, UnknownError, } from "./errors"; -import { Cookie } from "./types"; +import { Cookie } from "@expect/shared/models"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HttpClient } from "effect/unstable/http/HttpClient"; import { HttpClientResponse } from "effect/unstable/http"; @@ -138,7 +138,9 @@ export class CdpClient extends ServiceMap.Service()("@cookies/CdpClie const port = yield* Effect.tryPromise({ try: () => getPort(), catch: (cause) => - new ExtractionError({ reason: new UnknownError({ cause: String(cause) }) }), + new ExtractionError({ + reason: new UnknownError({ cause: String(cause) }), + }), }); yield* Effect.annotateCurrentSpan({ diff --git a/packages/cookies/src/chromium-sqlite.ts b/packages/cookies/src/chromium-sqlite.ts index 338d50d7f..e35abafee 100644 --- a/packages/cookies/src/chromium-sqlite.ts +++ b/packages/cookies/src/chromium-sqlite.ts @@ -1,7 +1,7 @@ // HACK: Fallback for Chromium cookie extraction when CDP (headless browser) fails. // Reads the SQLite cookie database directly and decrypts values using // platform-specific key retrieval (macOS Keychain, Linux secret-tool, Windows DPAPI). -import path from "node:path"; +import * as path from "node:path"; import * as os from "node:os"; import { Effect, Layer, Schema, ServiceMap } from "effect"; import * as FileSystem from "effect/FileSystem"; @@ -18,7 +18,7 @@ import { UnknownError, } from "./errors"; import { SqliteClient } from "./sqlite-client"; -import { Cookie, type ChromiumBrowser, type ChromiumBrowserKey } from "./types"; +import { Cookie, type ChromiumBrowser, type ChromiumBrowserKey } from "@expect/shared/models"; const CHROMIUM_META_VERSION_HASH_PREFIX = 24; const PBKDF2_ITERATIONS_DARWIN = 1003; diff --git a/packages/cookies/src/chromium.ts b/packages/cookies/src/chromium.ts index 0e579449b..8964a740b 100644 --- a/packages/cookies/src/chromium.ts +++ b/packages/cookies/src/chromium.ts @@ -1,11 +1,11 @@ -import path from "node:path"; +import * as path from "node:path"; import * as os from "node:os"; import { Array as Arr, Effect, Layer, Option, Predicate, Schema, ServiceMap } from "effect"; import * as FileSystem from "effect/FileSystem"; import { ChildProcess } from "effect/unstable/process"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ChromiumBrowser, type ChromiumBrowserKey } from "./types"; +import { ChromiumBrowser, type ChromiumBrowserKey } from "@expect/shared/models"; import { CHROMIUM_CONFIGS, type ChromiumConfig } from "./browser-config"; import { ListBrowsersError } from "./errors"; import { Browsers } from "./browser-detector"; diff --git a/packages/cookies/src/cookies.ts b/packages/cookies/src/cookies.ts index 7fcb7fe04..3619f52a5 100644 --- a/packages/cookies/src/cookies.ts +++ b/packages/cookies/src/cookies.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import * as path from "node:path"; import { Effect, Layer, Match, Option, Schema, SchemaGetter, ServiceMap } from "effect"; import * as FileSystem from "effect/FileSystem"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -7,7 +7,7 @@ import { SqliteClient } from "./sqlite-client"; import { ChromiumSqliteFallback } from "./chromium-sqlite"; import { ExtractionError, RequiresFullDiskAccess, UnknownError } from "./errors"; import { parseBinaryCookies } from "./utils/binary-cookies"; -import { SameSitePolicy, Cookie, type Browser } from "./types"; +import { SameSitePolicy, Cookie, type Browser } from "@expect/shared/models"; const SAME_SITE_NONE = 0; const SAME_SITE_LAX = 1; diff --git a/packages/cookies/src/firefox.ts b/packages/cookies/src/firefox.ts index 1b5fb2cb3..1c79e50a2 100644 --- a/packages/cookies/src/firefox.ts +++ b/packages/cookies/src/firefox.ts @@ -1,9 +1,9 @@ -import path from "node:path"; +import * as path from "node:path"; import * as os from "node:os"; import { parse } from "ini"; import { Effect, Layer, Predicate, Schema, ServiceMap } from "effect"; import * as FileSystem from "effect/FileSystem"; -import { FirefoxBrowser } from "./types"; +import { FirefoxBrowser } from "@expect/shared/models"; import { FIREFOX_CONFIG } from "./browser-config"; import { ListBrowsersError } from "./errors"; import { Browsers } from "./browser-detector"; diff --git a/packages/cookies/src/index.ts b/packages/cookies/src/index.ts index a070bc866..6254b341c 100644 --- a/packages/cookies/src/index.ts +++ b/packages/cookies/src/index.ts @@ -1,5 +1,5 @@ export { Cookies } from "./cookies"; -export { Browsers } from "./browser-detector"; +export { Browsers, BrowserProfileNotFoundError } from "./browser-detector"; export { CdpClient } from "./cdp-client"; export { SqliteClient, SqliteEngine } from "./sqlite-client"; export { ChromiumSource, ChromiumPlatform } from "./chromium"; @@ -31,17 +31,9 @@ export { FirefoxBrowser, SafariBrowser, Browser, + BrowserJson, Cookie, SameSitePolicy, - browserKeyOf, -} from "./types"; +} from "@expect/shared/models"; -export type { ExtractOptions } from "./types"; - -import { configByKey } from "./browser-config"; -import { browserKeyOf, type Browser } from "./types"; - -export const browserDisplayName = (browser: Browser): string => { - const config = configByKey(browserKeyOf(browser)); - return config?.displayName ?? browserKeyOf(browser); -}; +export type { ExtractOptions } from "@expect/shared/models"; diff --git a/packages/cookies/src/safari.ts b/packages/cookies/src/safari.ts index 981d76c86..e36d6e5e5 100644 --- a/packages/cookies/src/safari.ts +++ b/packages/cookies/src/safari.ts @@ -1,8 +1,8 @@ -import path from "node:path"; +import * as path from "node:path"; import * as os from "node:os"; import { Effect, Layer, Option, ServiceMap } from "effect"; import * as FileSystem from "effect/FileSystem"; -import { SafariBrowser } from "./types"; +import { SafariBrowser } from "@expect/shared/models"; import { SAFARI_CONFIG } from "./browser-config"; import { ListBrowsersError } from "./errors"; import { Browsers } from "./browser-detector"; diff --git a/packages/cookies/src/sqlite-client.ts b/packages/cookies/src/sqlite-client.ts index f4ec1bdaf..557a3946b 100644 --- a/packages/cookies/src/sqlite-client.ts +++ b/packages/cookies/src/sqlite-client.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import * as path from "node:path"; import { Effect, Layer, Scope, ServiceMap } from "effect"; import * as FileSystem from "effect/FileSystem"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/packages/cookies/src/types.ts b/packages/cookies/src/types.ts deleted file mode 100644 index 6d3197b3e..000000000 --- a/packages/cookies/src/types.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Order, Schema, SchemaGetter } from "effect"; - -export const SameSitePolicy = Schema.Literals(["Strict", "Lax", "None"] as const); -export type SameSitePolicy = typeof SameSitePolicy.Type; - -export const BrowserKey = Schema.Literals([ - "chrome", - "edge", - "brave", - "arc", - "dia", - "helium", - "chromium", - "vivaldi", - "opera", - "ghost", - "sidekick", - "yandex", - "iridium", - "thorium", - "sigmaos", - "wavebox", - "comet", - "blisk", - "firefox", - "safari", -] as const); -export type BrowserKey = typeof BrowserKey.Type; - -export const ChromiumBrowserKey = Schema.Literals([ - "chrome", - "edge", - "brave", - "arc", - "dia", - "helium", - "chromium", - "vivaldi", - "opera", - "ghost", - "sidekick", - "yandex", - "iridium", - "thorium", - "sigmaos", - "wavebox", - "comet", - "blisk", -] as const); -export type ChromiumBrowserKey = typeof ChromiumBrowserKey.Type; - -const DotlessDomain = Schema.String.pipe( - Schema.decodeTo(Schema.String, { - decode: SchemaGetter.transform((domain) => (domain.startsWith(".") ? domain.slice(1) : domain)), - encode: SchemaGetter.transform((domain) => domain), - }), -); - -export class Cookie extends Schema.Class("@cookies/Cookie")({ - name: Schema.String, - value: Schema.String, - domain: DotlessDomain, - path: Schema.String, - expires: Schema.optional( - Schema.Number.pipe( - Schema.decodeTo(Schema.Number, { - decode: SchemaGetter.transform((value) => Math.floor(value)), - encode: SchemaGetter.transform((value) => value), - }), - ), - ), - secure: Schema.Boolean, - httpOnly: Schema.Boolean, - sameSite: Schema.optional(SameSitePolicy), -}) { - static make = Schema.decodeUnknownSync(this); - - get playwrightFormat() { - const SESSION_EXPIRES = -1; - const domain = this.name.startsWith("__Host-") - ? this.domain - : this.domain.startsWith(".") - ? this.domain - : `.${this.domain}`; - - return { - name: this.name, - value: this.value, - domain, - path: this.path, - expires: this.expires ?? SESSION_EXPIRES, - secure: this.secure, - httpOnly: this.httpOnly, - sameSite: this.sameSite, - }; - } -} - -export class ChromiumBrowser extends Schema.Class("@cookies/ChromiumBrowser")({ - _tag: Schema.tag("ChromiumBrowser"), - key: ChromiumBrowserKey, - profileName: Schema.String, - profilePath: Schema.String, - executablePath: Schema.String, - locale: Schema.optional(Schema.String), -}) { - static orderBy = (lastUsedProfileName: string | undefined) => - Order.combine( - Order.mapInput( - Order.Boolean, - (profile: ChromiumBrowser) => profile.profileName === lastUsedProfileName, - ), - Order.mapInput( - Order.make( - (left: string, right: string) => - left.localeCompare(right, undefined, { numeric: true }) as -1 | 0 | 1, - ), - (profile: ChromiumBrowser) => profile.profileName, - ), - ); -} - -export class FirefoxBrowser extends Schema.Class("@cookies/FirefoxBrowser")({ - _tag: Schema.tag("FirefoxBrowser"), - profileName: Schema.String, - profilePath: Schema.String, -}) {} - -export class SafariBrowser extends Schema.Class("@cookies/SafariBrowser")({ - _tag: Schema.tag("SafariBrowser"), - cookieFilePath: Schema.OptionFromNullishOr(Schema.String), -}) {} - -export const Browser = Schema.Union([ChromiumBrowser, FirefoxBrowser, SafariBrowser]); -export type Browser = typeof Browser.Type; - -export interface ExtractOptions { - url: string; - browsers?: BrowserKey[]; - names?: string[]; - includeExpired?: boolean; -} - -export const browserKeyOf = (browser: Browser): BrowserKey => { - if (browser._tag === "ChromiumBrowser") return browser.key; - if (browser._tag === "FirefoxBrowser") return "firefox"; - return "safari"; -}; diff --git a/packages/cookies/src/utils/binary-cookies.ts b/packages/cookies/src/utils/binary-cookies.ts index ed71b20e1..3d699b2c2 100644 --- a/packages/cookies/src/utils/binary-cookies.ts +++ b/packages/cookies/src/utils/binary-cookies.ts @@ -1,4 +1,4 @@ -import { Cookie } from "../types"; +import { Cookie } from "@expect/shared/models"; const MAC_EPOCH_DELTA_SECONDS = 978_307_200; const BINARY_COOKIE_PAGE_HEADER = 0x00000100; diff --git a/packages/cookies/tests/browsers.test.ts b/packages/cookies/tests/browsers.test.ts index e22f4ef7e..8aa39966d 100644 --- a/packages/cookies/tests/browsers.test.ts +++ b/packages/cookies/tests/browsers.test.ts @@ -35,4 +35,30 @@ describe("Browsers", () => { ); } }).pipe(Effect.provide(layerLive), Effect.runPromise)); + + it("findById returns a browser matching the given id", () => + Effect.gen(function* () { + const browsers = yield* Browsers; + const allBrowsers = yield* browsers.list; + const first = allBrowsers[0]; + const found = yield* browsers.findById(first.id); + assert.strictEqual(found.id, first.id); + assert.strictEqual(found.displayName, first.displayName); + }).pipe(Effect.provide(layerLive), Effect.runPromise)); + + it("findById fails with available profile ids when profile does not exist", () => + Effect.gen(function* () { + const browsers = yield* Browsers; + const allBrowsers = yield* browsers.list; + const error = yield* browsers.findById("nonexistent/profile").pipe(Effect.flip); + if (error._tag === "ListBrowsersError") throw new Error(`Invalid error returned`); + assert.strictEqual(error._tag, "BrowserProfileNotFoundError"); + assert.strictEqual(error.profileId, "nonexistent/profile"); + assert.deepStrictEqual( + error.availableProfileIds, + allBrowsers.map((browser) => browser.id), + ); + assert.include(error.message, "nonexistent/profile"); + assert.include(error.message, "Available profiles:"); + }).pipe(Effect.provide(layerLive), Effect.runPromise)); }); diff --git a/packages/cookies/tests/cdp-client.test.ts b/packages/cookies/tests/cdp-client.test.ts index e3706d328..070514725 100644 --- a/packages/cookies/tests/cdp-client.test.ts +++ b/packages/cookies/tests/cdp-client.test.ts @@ -1,53 +1,95 @@ -import * as fs from "node:fs"; -import { assert, describe, it } from "vite-plus/test"; -import { Effect } from "effect"; -import { CdpClient } from "../src/cdp-client"; - -const CHROME_PROFILE_PATH = "/Users/rasmus/Library/Application Support/Google/Chrome/Default"; -const CHROME_EXECUTABLE_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; - -const CdpTestLayer = CdpClient.layer; - -const hasChrome = fs.existsSync(CHROME_PROFILE_PATH) && fs.existsSync(CHROME_EXECUTABLE_PATH); - -describe.skipIf(!hasChrome)("CdpClient", () => { - it("extracts cookies from a Chrome profile via CDP", { timeout: 30_000 }, () => - Effect.gen(function* () { - const cdpClient = yield* CdpClient; - const cookies = yield* cdpClient.extractCookies({ - key: "chrome", - profilePath: CHROME_PROFILE_PATH, - executablePath: CHROME_EXECUTABLE_PATH, - }); - - assert.isArray(cookies); - assert.isAbove(cookies.length, 0); - - const first = cookies[0]; - assert.isString(first.name); - assert.isString(first.value); - assert.isString(first.domain); - assert.isString(first.path); - assert.isBoolean(first.secure); - assert.isBoolean(first.httpOnly); - }).pipe(Effect.scoped, Effect.provide(CdpTestLayer), Effect.runPromise), +import { describe, it, assert } from "vite-plus/test"; +import { Effect, Layer, Option } from "effect"; +import { Browsers } from "../src/browser-detector"; +import { Cookies } from "../src/cookies"; +import { layerLive } from "../src/layers"; + +const FIVE_MINUTES_MS = 300_000; + +const TestLayer = Layer.mergeAll(layerLive, Cookies.layer); + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(TestLayer))); + +describe("CdpClient", () => { + it( + "default system browser is detected", + () => + run( + Effect.gen(function* () { + const browsers = yield* Browsers; + const defaultBrowser = yield* browsers.defaultBrowser(); + assert.isTrue(Option.isSome(defaultBrowser)); + }), + ), + FIVE_MINUTES_MS, + ); + + it( + "all profiles are listed", + () => + run( + Effect.gen(function* () { + const browsers = yield* Browsers; + const allBrowsers = yield* browsers.list; + assert.isAbove(allBrowsers.length, 0); + }), + ), + FIVE_MINUTES_MS, ); - it("returns cookies with stripped leading dots on domains", { timeout: 30_000 }, () => - Effect.gen(function* () { - const cdpClient = yield* CdpClient; - const cookies = yield* cdpClient.extractCookies({ - key: "chrome", - profilePath: CHROME_PROFILE_PATH, - executablePath: CHROME_EXECUTABLE_PATH, - }); - - for (const cookie of cookies) { - assert.isFalse( - cookie.domain.startsWith("."), - `domain should not start with dot: ${cookie.domain}`, - ); - } - }).pipe(Effect.scoped, Effect.provide(CdpTestLayer), Effect.runPromise), + it( + "no profile named System Profile", + () => + run( + Effect.gen(function* () { + const browsers = yield* Browsers; + const allBrowsers = yield* browsers.list; + const systemProfiles = allBrowsers.filter( + (browser) => + browser._tag === "ChromiumBrowser" && browser.profileName === "System Profile", + ); + assert.strictEqual(systemProfiles.length, 0, "System Profile should be filtered out"); + }), + ), + FIVE_MINUTES_MS, + ); + + it( + "each profile has at least 5 cookies", + () => + run( + Effect.gen(function* () { + const browsers = yield* Browsers; + const cookies = yield* Cookies; + const allBrowsers = yield* browsers.list; + + for (const browser of allBrowsers) { + const label = + browser._tag === "ChromiumBrowser" + ? `${browser.key}/${browser.profileName}` + : browser._tag === "FirefoxBrowser" + ? `firefox/${browser.profileName}` + : "safari"; + if (browser._tag === "SafariBrowser") { + /* Safari's cookie file requires Full Disk Access (System Settings → Privacy & Security → Full Disk Access) for the terminal/IDE running the test. */ + const result = yield* Effect.suspend(() => cookies.extract(browser)).pipe( + Effect.catchTag("ExtractionError", () => Effect.succeed(undefined)), + Effect.catchTag("PlatformError", () => Effect.succeed(undefined)), + ); + if (result === undefined) continue; + } + + const result = yield* cookies.extract(browser); + + assert.isAbove( + result.length, + 4, + `${label}: expected > 4 cookies, got ${result.length}`, + ); + } + }), + ), + FIVE_MINUTES_MS, ); }); diff --git a/packages/cookies/tests/cookies.test.ts b/packages/cookies/tests/cookies.test.ts index a2e2fda75..356f98225 100644 --- a/packages/cookies/tests/cookies.test.ts +++ b/packages/cookies/tests/cookies.test.ts @@ -4,7 +4,7 @@ import { Effect, Layer } from "effect"; import { Browsers } from "../src/browser-detector"; import { Cookies } from "../src/cookies"; import { layerLive } from "../src/layers"; -import type { Cookie } from "../src/types"; +import type { Cookie } from "@expect/shared/models"; const FIVE_MINUTES_MS = 300_000; diff --git a/packages/cookies/tests/services.test.ts b/packages/cookies/tests/services.test.ts index f68bf9313..bfb8485c2 100644 --- a/packages/cookies/tests/services.test.ts +++ b/packages/cookies/tests/services.test.ts @@ -1,7 +1,6 @@ import * as fs from "node:fs"; import * as os from "node:os"; -import path from "node:path"; -// @ts-expect-error node:sqlite lacks type declarations +import * as path from "node:path"; import * as sqlite from "node:sqlite"; import { assert, describe, it } from "vite-plus/test"; import { Effect, Layer, Option } from "effect"; @@ -10,7 +9,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { Cookies } from "../src/cookies"; import { BROWSER_CONFIGS } from "../src/browser-config"; import { parseBinaryCookies } from "../src/utils/binary-cookies"; -import { FirefoxBrowser, SafariBrowser } from "../src/types"; +import { FirefoxBrowser, SafariBrowser } from "@expect/shared/models"; const CookiesTestRuntime = Layer.merge(Cookies.layerTest, NodeServices.layer); diff --git a/packages/cookies/tsconfig.json b/packages/cookies/tsconfig.json index a086b149d..942c0f924 100644 --- a/packages/cookies/tsconfig.json +++ b/packages/cookies/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "dist", - "rootDir": "src" + "outDir": "dist" }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/packages/shared/package.json b/packages/shared/package.json index a58748f8d..dae5d1388 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,6 +9,7 @@ "./prompts": "./src/prompts.ts", "./observability": "./src/observability/exports.ts", "./utils": "./src/utils.ts", + "./rpcs": "./src/rpcs.ts", "./launched-from": "./src/launched-from.ts", "./is-command-available": "./src/is-command-available.ts" }, diff --git a/packages/shared/src/analytics/analytics.ts b/packages/shared/src/analytics/analytics.ts index 6fc66a547..3f4b2692a 100644 --- a/packages/shared/src/analytics/analytics.ts +++ b/packages/shared/src/analytics/analytics.ts @@ -37,8 +37,8 @@ export class AnalyticsProvider extends ServiceMap.Service< >()("@expect/AnalyticsProvider") { static layerPostHog = Layer.succeed(this)({ capture: (event) => - Effect.sync(() => { - posthogClient.captureImmediate({ + Effect.promise(() => { + return posthogClient.captureImmediate({ event: event.eventName, properties: event.properties, distinctId: event.distinctId, @@ -105,9 +105,9 @@ export class Analytics extends ServiceMap.Service()("@expect/Analytic return machineId(); }).pipe( Effect.catchTag("UnknownError", (cause) => - Effect.logWarning("Failed to get machine ID, using fallback", { cause }).pipe( - Effect.as(globalThis.crypto.randomUUID()), - ), + Effect.logWarning("Failed to get machine ID, using fallback", { + cause, + }).pipe(Effect.as(globalThis.crypto.randomUUID())), ), ); @@ -158,7 +158,11 @@ export class Analytics extends ServiceMap.Service()("@expect/Analytic ); })) as never; - return { capture, track, flush: telemetryDisabled ? Effect.void : provider.flush } as const; + return { + capture, + track, + flush: telemetryDisabled ? Effect.void : provider.flush, + } as const; }), }) { static layerPostHog = Layer.effect(this)(this.make).pipe( diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 44e72e787..5c5ee35d6 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -5,3 +5,9 @@ export const BROWSER_MEMORY_OVERHEAD_MB = 150; export const MEMORY_SAFETY_RATIO = 0.75; export const BYTES_PER_MB = 1024 * 1024; export const FALLBACK_CPU_CORES = 1; + +export const LIVE_VIEWER_RPC_PORT = 38930; +export const LIVE_VIEWER_RPC_URL = `ws://localhost:${LIVE_VIEWER_RPC_PORT}/rpc`; +export const LIVE_VIEWER_STATIC_PORT = 38931; +export const LIVE_VIEWER_STATIC_URL = `http://localhost:${LIVE_VIEWER_STATIC_PORT}`; +export const DEFAULT_REPLAY_HOST = "https://expect-git-chore-browser-mcp-cleanup.ami.construction"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ca781e21d..48e1b60f1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,11 @@ export { BROWSER_MEMORY_OVERHEAD_MB, + DEFAULT_REPLAY_HOST, DEFAULT_TIMEOUT_MS, + LIVE_VIEWER_RPC_PORT, + LIVE_VIEWER_RPC_URL, + LIVE_VIEWER_STATIC_PORT, + LIVE_VIEWER_STATIC_URL, MEMORY_SAFETY_RATIO, MS_PER_SECOND, } from "./constants"; diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index eff1105a8..68ca5da97 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -1,4 +1,208 @@ -import { DateTime, Match, Option, Predicate, Schema } from "effect"; +import { + DateTime, + Effect, + Match, + Option, + Order, + Predicate, + Schema, + SchemaGetter, + ServiceMap, +} from "effect"; + +export const BrowserKey = Schema.Literals([ + "chrome", + "edge", + "brave", + "arc", + "dia", + "helium", + "chromium", + "vivaldi", + "opera", + "ghost", + "sidekick", + "yandex", + "iridium", + "thorium", + "sigmaos", + "wavebox", + "comet", + "blisk", + "firefox", + "safari", +] as const); +export type BrowserKey = typeof BrowserKey.Type; + +export const ChromiumBrowserKey = Schema.Literals([ + "chrome", + "edge", + "brave", + "arc", + "dia", + "helium", + "chromium", + "vivaldi", + "opera", + "ghost", + "sidekick", + "yandex", + "iridium", + "thorium", + "sigmaos", + "wavebox", + "comet", + "blisk", +] as const); +export type ChromiumBrowserKey = typeof ChromiumBrowserKey.Type; + +export class ChromiumBrowser extends Schema.Class("@cookies/ChromiumBrowser")({ + _tag: Schema.tag("ChromiumBrowser"), + key: ChromiumBrowserKey, + profileName: Schema.String, + profilePath: Schema.String, + executablePath: Schema.String, + locale: Schema.optional(Schema.String), +}) { + static displayNames: Record = { + chrome: "Google Chrome", + edge: "Microsoft Edge", + brave: "Brave", + arc: "Arc", + dia: "Dia", + helium: "Helium", + chromium: "Chromium", + vivaldi: "Vivaldi", + opera: "Opera", + ghost: "Ghost Browser", + sidekick: "Sidekick", + yandex: "Yandex", + iridium: "Iridium", + thorium: "Thorium", + sigmaos: "SigmaOS", + wavebox: "Wavebox", + comet: "Comet", + blisk: "Blisk", + }; + + get displayName(): string { + return ChromiumBrowser.displayNames[this.key] ?? this.key; + } + + get id(): string { + return `${this.key}/${this.profileName}`; + } + + static orderBy = (lastUsedProfileName: string | undefined) => + Order.combine( + Order.mapInput( + Order.Boolean, + (profile: ChromiumBrowser) => profile.profileName === lastUsedProfileName, + ), + Order.mapInput( + Order.make( + (left: string, right: string) => + left.localeCompare(right, undefined, { numeric: true }) as -1 | 0 | 1, + ), + (profile: ChromiumBrowser) => profile.profileName, + ), + ); +} + +export class FirefoxBrowser extends Schema.Class("@cookies/FirefoxBrowser")({ + _tag: Schema.tag("FirefoxBrowser"), + profileName: Schema.String, + profilePath: Schema.String, +}) { + get displayName(): string { + return "Firefox"; + } + + get id(): string { + return `firefox/${this.profileName}`; + } +} + +export class SafariBrowser extends Schema.Class("@cookies/SafariBrowser")({ + _tag: Schema.tag("SafariBrowser"), + cookieFilePath: Schema.OptionFromNullishOr(Schema.String), +}) { + get displayName(): string { + return "Safari"; + } + + get id(): string { + return "safari"; + } +} + +export const Browser = Schema.Union([ChromiumBrowser, FirefoxBrowser, SafariBrowser]); +export type Browser = typeof Browser.Type; + +export const BrowserJson = Schema.fromJsonString(Schema.toCodecJson(Browser)); + +export const browserKeyOf = (browser: Browser): BrowserKey => { + if (browser._tag === "ChromiumBrowser") return browser.key; + if (browser._tag === "FirefoxBrowser") return "firefox"; + return "safari"; +}; + +export const SameSitePolicy = Schema.Literals(["Strict", "Lax", "None"] as const); +export type SameSitePolicy = typeof SameSitePolicy.Type; + +const DotlessDomain = Schema.String.pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transform((domain) => (domain.startsWith(".") ? domain.slice(1) : domain)), + encode: SchemaGetter.transform((domain) => domain), + }), +); + +export class Cookie extends Schema.Class("@cookies/Cookie")({ + name: Schema.String, + value: Schema.String, + domain: DotlessDomain, + path: Schema.String, + expires: Schema.optional( + Schema.Number.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((value) => Math.floor(value)), + encode: SchemaGetter.transform((value) => value), + }), + ), + ), + secure: Schema.Boolean, + httpOnly: Schema.Boolean, + sameSite: Schema.optional(SameSitePolicy), +}) { + static make = Schema.decodeUnknownSync(this); + + get playwrightFormat() { + const SESSION_EXPIRES = -1; + const domain = this.name.startsWith("__Host-") + ? this.domain + : this.domain.startsWith(".") + ? this.domain + : `.${this.domain}`; + + return { + name: this.name, + value: this.value, + domain, + path: this.path, + expires: this.expires ?? SESSION_EXPIRES, + secure: this.secure, + httpOnly: this.httpOnly, + sameSite: this.sameSite, + }; + } +} + +export interface ExtractOptions { + url: string; + browsers?: BrowserKey[]; + names?: string[]; + includeExpired?: boolean; +} export interface SavedFlowStep { id: string; @@ -315,9 +519,56 @@ export class GitState extends Schema.Class("@supervisor/GitState")({ export const StepId = Schema.String.pipe(Schema.brand("StepId")); export type StepId = typeof StepId.Type; -export const PlanId = Schema.String.pipe(Schema.brand("PlanId")); +export const PlanId = Schema.NonEmptyString.pipe(Schema.brand("PlanId")); export type PlanId = typeof PlanId.Type; +export class CurrentPlanId extends ServiceMap.Service()( + "@shared/CurrentPlanId", +) {} + +export class ConsoleLog extends Schema.TaggedClass()("ConsoleLog", { + type: Schema.String, + text: Schema.String, + timestamp: Schema.Number, +}) {} + +export class NetworkRequest extends Schema.TaggedClass()("NetworkRequest", { + url: Schema.String, + method: Schema.String, + status: Schema.UndefinedOr(Schema.Number), + resourceType: Schema.String, + timestamp: Schema.Number, +}) {} + +export class RrwebEvent extends Schema.TaggedClass()("RrwebEvent", { + event: Schema.Unknown, +}) {} + +export class PerformanceTrace extends Schema.TaggedClass()("PerformanceTrace", { + trace: Schema.String, +}) {} + +export class InitialPlan extends Schema.TaggedClass()("InitialPlan", { + plan: Schema.suspend(() => TestPlan), +}) {} + +export class SessionUpdate extends Schema.TaggedClass()("SessionUpdate", { + update: AcpSessionUpdate, +}) {} + +export class Done extends Schema.TaggedClass()("Done", {}) {} + +export const Artifact = Schema.Union([ + ConsoleLog, + NetworkRequest, + RrwebEvent, + PerformanceTrace, + InitialPlan, + SessionUpdate, + Done, +]); +export type Artifact = typeof Artifact.Type; + export const ChangesFor = Schema.TaggedUnion({ WorkingTree: {}, Branch: { mainBranch: Schema.String }, @@ -418,6 +669,13 @@ export class TestPlanStep extends Schema.Class("@supervisor/TestPl ): TestPlanStep { return new TestPlanStep({ ...this, ...fields }); } + + get elapsedMs(): number | undefined { + if (Option.isNone(this.startedAt) || Option.isNone(this.endedAt)) return undefined; + return Number( + DateTime.toEpochMillis(this.endedAt.value) - DateTime.toEpochMillis(this.startedAt.value), + ); + } } export class TestCoverageEntry extends Schema.Class( @@ -449,16 +707,16 @@ export class TestPlanDraft extends Schema.Class("@supervisor/Test instruction: Schema.String, baseUrl: Schema.Option(Schema.String), isHeadless: Schema.Boolean, - cookieBrowserKeys: Schema.Array(Schema.String), + cookieImportProfiles: Schema.Array(Browser), testCoverage: Schema.Option(TestCoverageReport), }) { get requiresCookies(): boolean { - return this.cookieBrowserKeys.length > 0; + return this.cookieImportProfiles.length > 0; } update( fields: Partial< - Pick + Pick >, ): TestPlanDraft { return new TestPlanDraft({ ...this, ...fields }); @@ -473,7 +731,7 @@ export class TestPlan extends TestPlanDraft.extend("@supervisor/TestPl }) { update( fields: Partial< - Pick + Pick >, ): TestPlan { return new TestPlan({ ...this, ...fields }); @@ -1048,12 +1306,26 @@ export class ExecutedTestPlan extends TestPlan.extend( } } +export class TestFailedError extends Schema.ErrorClass("TestFailedError")({ + _tag: Schema.tag("TestFailedError"), + report: Schema.suspend(() => TestReport), +}) { + message = `Test "${this.report.title}" failed: ${this.report.summary}`; +} + export class TestReport extends ExecutedTestPlan.extend("@supervisor/TestReport")({ summary: Schema.String, screenshotPaths: Schema.Array(Schema.String), pullRequest: Schema.Option(Schema.suspend(() => PullRequest)), testCoverageReport: Schema.Option(TestCoverageReport), }) { + static json = Schema.fromJsonString(Schema.toCodecJson(this)); + + assertSuccess = () => { + if (this.status === "passed") return Effect.void; + return Effect.fail(new TestFailedError({ report: this })); + }; + /** @todo(rasmus): UNUSED */ get stepStatuses(): ReadonlyMap< StepId, @@ -1093,17 +1365,104 @@ export class TestReport extends ExecutedTestPlan.extend("@supervisor return "passed"; } + get passedStepCount(): number { + return this.steps.filter((step) => this.stepStatuses.get(step.id)?.status === "passed").length; + } + + get failedStepCount(): number { + return this.steps.filter((step) => this.stepStatuses.get(step.id)?.status === "failed").length; + } + + get skippedStepCount(): number { + return this.steps.filter((step) => this.stepStatuses.get(step.id)?.status === "skipped").length; + } + + get totalDurationMs(): number { + let totalMs = 0; + for (const step of this.steps) { + if (Option.isNone(step.startedAt) || Option.isNone(step.endedAt)) continue; + totalMs += Number( + DateTime.toEpochMillis(step.endedAt.value) - DateTime.toEpochMillis(step.startedAt.value), + ); + } + return totalMs; + } + + get toGithubComment(): string { + const statusEmoji = this.status === "passed" ? "\u2705" : "\u274c"; + const statusLabel = this.status === "passed" ? "Passed" : "Failed"; + const escapeTableCell = (text: string) => text.replace(/\|/g, "\\|").replace(/\n/g, " "); + const statuses = this.stepStatuses; + + const stepRows = this.steps + .map((step) => { + const entry = statuses.get(step.id); + const stepStatus = entry?.status ?? "not-run"; + const stepIcon = + stepStatus === "passed" + ? "\u2713" + : stepStatus === "failed" + ? "\u2717" + : stepStatus === "skipped" + ? "\u2192" + : "\u2013"; + const stepSummary = entry?.summary ?? ""; + const stepStartedAt = step.startedAt._tag === "Some" ? step.startedAt.value : undefined; + const stepEndedAt = step.endedAt._tag === "Some" ? step.endedAt.value : undefined; + const stepTime = + stepStartedAt && stepEndedAt + ? Number(DateTime.toEpochMillis(stepEndedAt) - DateTime.toEpochMillis(stepStartedAt)) + : undefined; + const timeLabel = stepTime !== undefined ? `${Math.round(stepTime / 1000)}s` : "-"; + const statusCell = + stepStatus === "failed" ? `${stepIcon} ${escapeTableCell(stepSummary)}` : stepIcon; + return `| ${escapeTableCell(step.title)} | ${statusCell} | ${timeLabel} |`; + }) + .join("\n"); + + const maxBacktickRun = (this.toPlainText.match(/`+/g) ?? []).reduce( + (max, run) => Math.max(max, run.length), + 2, + ); + const fence = "`".repeat(maxBacktickRun + 1); + + return [ + "", + `## expect test results`, + "", + `**${statusEmoji} ${statusLabel}** \u2014 ${this.steps.length} step${ + this.steps.length === 1 ? "" : "s" + } in ${Math.round(this.totalDurationMs / 1000)}s`, + "", + "| Step | Status | Time |", + "|------|--------|------|", + stepRows, + "", + "
    Full output", + "", + fence, + this.toPlainText, + fence, + "", + "
    ", + ].join("\n"); + } + + get toGithubStepSummary(): string { + const maxBacktickRun = (this.toPlainText.match(/`+/g) ?? []).reduce( + (max, run) => Math.max(max, run.length), + 2, + ); + const fence = "`".repeat(maxBacktickRun + 1); + const badge = this.status === "passed" ? "**Result: PASSED**" : "**Result: FAILED**"; + return `## expect test results\n\n${badge}\n\n${fence}\n${this.toPlainText}\n${fence}\n`; + } + get toPlainText(): string { const statuses = this.stepStatuses; - const passedCount = this.steps.filter( - (step) => statuses.get(step.id)?.status === "passed", - ).length; - const failedCount = this.steps.filter( - (step) => statuses.get(step.id)?.status === "failed", - ).length; - const skippedCount = this.steps.filter( - (step) => statuses.get(step.id)?.status === "skipped", - ).length; + const passedCount = this.passedStepCount; + const failedCount = this.failedStepCount; + const skippedCount = this.skippedStepCount; const icon = this.status === "passed" ? "\u2705" : "\u274C"; const summaryParts = [`${passedCount} passed`, `${failedCount} failed`]; diff --git a/packages/shared/src/observability/agent-logger.ts b/packages/shared/src/observability/agent-logger.ts index 348d0e66b..fcc356412 100644 --- a/packages/shared/src/observability/agent-logger.ts +++ b/packages/shared/src/observability/agent-logger.ts @@ -1,6 +1,6 @@ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import { Effect, FileSystem, Layer, Logger } from "effect"; -import path from "node:path"; +import * as path from "node:path"; const LOG_FILE = path.join(process.cwd(), ".expect", "logs.md"); @@ -15,7 +15,7 @@ const EnsureDebugLogDirectoryLayer = Layer.effectDiscard( }), ); -export const DebugFileLoggerLayer = Logger.layer([DebugFileLogger]).pipe( +export const DebugFileLoggerLayer = Logger.layer([DebugFileLogger, Logger.consolePretty()]).pipe( Layer.provide(EnsureDebugLogDirectoryLayer), Layer.provide(NodeFileSystem.layer), ); diff --git a/packages/shared/src/prompts.ts b/packages/shared/src/prompts.ts index ab08d8a7e..1278009b6 100644 --- a/packages/shared/src/prompts.ts +++ b/packages/shared/src/prompts.ts @@ -1,3 +1,4 @@ +import type { Browser } from "@expect/cookies"; import type { ChangedFile, ChangesFor, @@ -27,7 +28,7 @@ export interface ExecutionPromptOptions { readonly diffPreview: string; readonly baseUrl: string | undefined; readonly isHeadless: boolean; - readonly cookieBrowserKeys: readonly string[]; + readonly cookieImportProfiles: readonly Browser[]; readonly browserMcpServerName?: string; readonly savedFlow?: SavedFlow; readonly learnings?: string; @@ -320,7 +321,7 @@ export const buildExecutionPrompt = (options: ExecutionPromptOptions): string => ...(options.baseUrl ? [`Base URL: ${options.baseUrl}`] : []), ...devServerLines, `Browser is headless: ${options.isHeadless ? "yes" : "no"}`, - `Uses existing browser cookies: ${options.cookieBrowserKeys.length > 0 ? `yes (${options.cookieBrowserKeys.length})` : "no"}`, + `Uses existing browser cookies: ${options.cookieImportProfiles.length > 0 ? `yes (${options.cookieImportProfiles.map((profile) => profile._tag).join(", ")})` : "no"}`, `Scope: ${options.scope}`, `Current branch: ${options.currentBranch}`, ...(options.mainBranch ? [`Main branch: ${options.mainBranch}`] : []), diff --git a/packages/shared/src/rpc/artifact.rpc.ts b/packages/shared/src/rpc/artifact.rpc.ts new file mode 100644 index 000000000..aa51e4e78 --- /dev/null +++ b/packages/shared/src/rpc/artifact.rpc.ts @@ -0,0 +1,34 @@ +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { Artifact, PlanId, TestPlan } from "../models"; + +const ArtifactRpcsBase = RpcGroup.make( + Rpc.make("PushArtifacts", { + success: Schema.Void, + payload: { + planId: PlanId, + batch: Schema.Array(Artifact), + }, + }), + + Rpc.make("StreamEvents", { + success: Artifact, + stream: true, + payload: { + planId: PlanId, + }, + }), + + Rpc.make("GetAllArtifacts", { + success: Schema.Array(Artifact), + payload: { + planId: PlanId, + }, + }), + + Rpc.make("ListTests", { + success: Schema.Array(TestPlan), + }), +); + +export const ArtifactRpcs = ArtifactRpcsBase.prefix("artifact."); diff --git a/packages/shared/src/rpcs.ts b/packages/shared/src/rpcs.ts new file mode 100644 index 000000000..f266090e0 --- /dev/null +++ b/packages/shared/src/rpcs.ts @@ -0,0 +1 @@ +export { ArtifactRpcs } from "./rpc/artifact.rpc"; diff --git a/packages/shared/tests/dynamic-steps.test.ts b/packages/shared/tests/dynamic-steps.test.ts index 516a80ea5..d9234e5bb 100644 --- a/packages/shared/tests/dynamic-steps.test.ts +++ b/packages/shared/tests/dynamic-steps.test.ts @@ -30,7 +30,7 @@ const makeEmptyPlan = (): TestPlan => instruction: "test", baseUrl: Option.none(), isHeadless: false, - cookieBrowserKeys: [], + cookieImportProfiles: [], testCoverage: Option.none(), }); diff --git a/packages/shared/tests/prompts.test.ts b/packages/shared/tests/prompts.test.ts index bf381f77f..cb664fde6 100644 --- a/packages/shared/tests/prompts.test.ts +++ b/packages/shared/tests/prompts.test.ts @@ -22,7 +22,7 @@ const makeDefaultOptions = ( diffPreview: "diff --git a/src/auth/login.ts\n+export const login = () => {}", baseUrl: "http://localhost:3000", isHeadless: false, - cookieBrowserKeys: [], + cookieImportProfiles: [], ...overrides, }); diff --git a/packages/shared/tests/to-plain-text.test.ts b/packages/shared/tests/to-plain-text.test.ts index 86a9cf1d0..97473fe42 100644 --- a/packages/shared/tests/to-plain-text.test.ts +++ b/packages/shared/tests/to-plain-text.test.ts @@ -46,7 +46,7 @@ const makePlan = (steps: TestPlanStep[]): TestPlan => instruction: "test", baseUrl: Option.none(), isHeadless: false, - cookieBrowserKeys: [], + cookieImportProfiles: [], testCoverage: Option.none(), }); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 57bf531c0..74ca7f8f2 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -2,8 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src", "types": ["node"] }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/packages/supervisor/package.json b/packages/supervisor/package.json index 3509a4a80..281e7c83e 100644 --- a/packages/supervisor/package.json +++ b/packages/supervisor/package.json @@ -23,8 +23,11 @@ "@expect/browser": "workspace:*", "@expect/shared": "workspace:*", "effect": "4.0.0-beta.35", + "figures": "^6.1.0", "oxc-resolver": "^11.19.1", "pathe": "^2.0.3", + "picocolors": "^1.1.1", + "pretty-ms": "^9.3.0", "simple-git": "^3.33.0" }, "devDependencies": { diff --git a/packages/supervisor/src/artifact-store.ts b/packages/supervisor/src/artifact-store.ts new file mode 100644 index 000000000..5cc066f07 --- /dev/null +++ b/packages/supervisor/src/artifact-store.ts @@ -0,0 +1,122 @@ +import * as path from "node:path"; +import { + Array as Arr, + Effect, + FileSystem, + Layer, + Predicate, + Schema, + ServiceMap, + String as Str, + Stream, +} from "effect"; +import { Ndjson } from "effect/unstable/encoding"; +import { Artifact, PlanId, type TestPlan } from "@expect/shared/models"; +import { NodeServices } from "@effect/platform-node"; +import { GitRepoRoot } from "./git/git"; +import { ensureStateDir } from "./utils/ensure-state-dir"; +import { ARTIFACTS_DIRECTORY_NAME } from "./constants"; +import { Tail } from "./tail"; + +export class ReplayCorruptedError extends Schema.ErrorClass( + "ReplayCorruptedError", +)({ + _tag: Schema.tag("ReplayCorruptedError"), + planId: Schema.String, + reason: Schema.String, +}) { + message = `Corrupted replay file for ${this.planId}: ${this.reason}`; +} + +const artifactPath = (artifactsDir: string, planId: PlanId) => + path.join(artifactsDir, `${planId}.ndjson`); + +const ArtifactJson = Schema.toCodecJson(Artifact); +const encodePayload = Schema.encodeEffect(Schema.fromJsonString(ArtifactJson)); + +export class ArtifactStore extends ServiceMap.Service()( + "@supervisor/ArtifactStore", + { + make: Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const repoRoot = yield* GitRepoRoot; + const tail = yield* Tail; + + const stateDir = yield* ensureStateDir(fileSystem, repoRoot); + const artifactsDir = path.join(stateDir, ARTIFACTS_DIRECTORY_NAME); + yield* fileSystem + .makeDirectory(artifactsDir, { recursive: true }) + .pipe(Effect.catchReason("PlatformError", "AlreadyExists", () => Effect.void)); + + const appendLine = Effect.fnUntraced(function* (planId: PlanId, payload: Artifact) { + const json = yield* encodePayload(payload).pipe(Effect.orDie); + yield* fileSystem + .writeFileString(artifactPath(artifactsDir, planId), json + "\n", { + flag: "a", + }) + .pipe(Effect.orDie); + }); + + const push = Effect.fn("ArtifactStore.push")(function* (planId: PlanId, payload: Artifact) { + yield* appendLine(planId, payload); + }); + + const stream = Effect.fn("ArtifactStore.stream")(function* (planId: PlanId) { + const filePath = artifactPath(artifactsDir, planId); + yield* Effect.logDebug(`Streaming artifacts from ${filePath}`); + return tail.stream(filePath).pipe( + Stream.pipeThroughChannel(Ndjson.decodeSchema(ArtifactJson)()), + Stream.orDie, + Stream.takeWhile((payload) => payload._tag !== "Done"), + ); + }, Stream.unwrap); + + const readAll = Effect.fn("ArtifactStore.readAll")(function* (planId: PlanId) { + const filePath = artifactPath(artifactsDir, planId); + return yield* fileSystem + .stream(filePath) + .pipe( + Stream.pipeThroughChannel(Ndjson.decodeSchema(ArtifactJson)()), + Stream.runCollect, + Effect.orDie, + ); + }); + + const listTests = Effect.fn("ArtifactStore.listTests")(function* () { + const ndjsonFiles = yield* fileSystem + .readDirectory(artifactsDir) + .pipe(Effect.map(Arr.filter(Str.endsWith(".ndjson"))), Effect.orDie); + + const plans = yield* Effect.forEach( + ndjsonFiles, + (fileName) => { + const filePath = path.join(artifactsDir, fileName); + return fileSystem.stream(filePath).pipe( + Stream.pipeThroughChannel(Ndjson.decodeSchema(ArtifactJson)()), + Stream.runHead, + Effect.orDie, + Effect.flatMap((head) => + Effect.gen(function* () { + const first = yield* head; + if (first._tag !== "InitialPlan") return undefined; + return first.plan as TestPlan; + }), + ), + Effect.catchTag("NoSuchElementError", () => Effect.succeed(undefined)), + ); + }, + { concurrency: "unbounded" }, + ).pipe(Effect.map(Arr.filter(Predicate.isNotUndefined))); + + return plans; + }); + + return { push, stream, readAll, listTests } as const; + }), + }, +) { + static layer = Layer.effect(this)(this.make).pipe( + Layer.provide(Tail.layer), + Layer.provide(NodeServices.layer), + ); +} diff --git a/packages/supervisor/src/constants.ts b/packages/supervisor/src/constants.ts index 595082821..e3b2cde3d 100644 --- a/packages/supervisor/src/constants.ts +++ b/packages/supervisor/src/constants.ts @@ -9,6 +9,8 @@ export const EXECUTION_CONTEXT_FILE_LIMIT = 12; export const EXECUTION_RECENT_COMMIT_LIMIT = 5; export const EXPECT_STATE_DIR = ".expect"; export const EXPECT_REPLAY_OUTPUT_ENV_NAME = "EXPECT_REPLAY_OUTPUT_PATH"; +export const EXPECT_PLAN_ID_ENV_NAME = "EXPECT_PLAN_ID"; +export const ARTIFACTS_DIRECTORY_NAME = "artifacts"; export const TESTED_FINGERPRINT_FILE = "last-tested"; export const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]; diff --git a/packages/supervisor/src/detect-project.ts b/packages/supervisor/src/detect-project.ts index 24655cc5e..93753e3cd 100644 --- a/packages/supervisor/src/detect-project.ts +++ b/packages/supervisor/src/detect-project.ts @@ -1,5 +1,5 @@ import { Effect, FileSystem, Schema } from "effect"; -import { join } from "node:path"; +import * as path from "node:path"; import { FRAMEWORK_DEFAULT_PORTS } from "./constants"; type Framework = @@ -66,7 +66,7 @@ const detectFramework = (packageJson: PackageJson | undefined): Framework => { const readPackageJson = Effect.fn("detectProject.readPackageJson")(function* (projectRoot: string) { const fileSystem = yield* FileSystem.FileSystem; - const packageJsonPath = join(projectRoot, "package.json"); + const packageJsonPath = path.join(projectRoot, "package.json"); const content = yield* fileSystem .readFileString(packageJsonPath) @@ -101,7 +101,7 @@ const detectPortFromViteConfig = Effect.fn("detectProject.detectPortFromViteConf if (!viteConfig) return undefined; const content = yield* fileSystem - .readFileString(join(projectRoot, viteConfig)) + .readFileString(path.join(projectRoot, viteConfig)) .pipe(Effect.catchTag("PlatformError", () => Effect.succeed(undefined))); if (!content) return undefined; diff --git a/packages/supervisor/src/executor.ts b/packages/supervisor/src/executor.ts index 31f64308d..32519ed5f 100644 --- a/packages/supervisor/src/executor.ts +++ b/packages/supervisor/src/executor.ts @@ -1,4 +1,3 @@ -import * as path from "node:path"; import { AcpProviderUnauthenticatedError, AcpProviderUsageLimitError, @@ -7,16 +6,35 @@ import { Agent, AgentStreamOptions, } from "@expect/agent"; -import { Effect, Layer, Option, Schema, ServiceMap, Stream } from "effect"; +import { + Effect, + FileSystem, + Layer, + Option, + Schema, + ServiceMap, + Stream, + String as Str, + Array, + pipe, + identity, +} from "effect"; +import { ArtifactStore } from "./artifact-store"; +import { OutputReporter } from "./output-reporter"; import { type AcpConfigOption, + AcpSessionUpdate, type ChangesFor, type ChangedFile, type CommitSummary, + CurrentPlanId, + Done, ExecutedTestPlan, + InitialPlan, PlanId, RunStarted, type SavedFlow, + SessionUpdate, type TestCoverageReport, TestPlan, } from "@expect/shared/models"; @@ -27,19 +45,21 @@ import { } from "@expect/shared/prompts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Git } from "./git/git"; -import { - EXPECT_LIVE_VIEW_URL_ENV_NAME, - EXPECT_COOKIE_BROWSERS_ENV_NAME, - EXPECT_BASE_URL_ENV_NAME, -} from "@expect/browser/mcp"; +import { EXPECT_BASE_URL_ENV_NAME } from "@expect/browser/mcp"; +import { BrowserJson, type Browser } from "@expect/cookies"; +import { EXPECT_BROWSER_PROFILE_ENV_NAME, EXPECT_HEADED_ENV_NAME } from "@expect/browser/mcp"; import { ALL_STEPS_TERMINAL_GRACE_MS, EXECUTION_CONTEXT_FILE_LIMIT, EXECUTION_RECENT_COMMIT_LIMIT, - EXPECT_REPLAY_OUTPUT_ENV_NAME, - EXPECT_STATE_DIR, } from "./constants"; +const encodeSessionUpdate = Schema.encodeEffect( + Schema.fromJsonString(Schema.toCodecJson(AcpSessionUpdate)), +); + +const encodeBrowserProfile = Schema.encodeEffect(BrowserJson); + export class ExecutionError extends Schema.ErrorClass("@supervisor/ExecutionError")( { _tag: Schema.tag("ExecutionError"), @@ -59,15 +79,15 @@ export interface ExecuteOptions { readonly changesFor: ChangesFor; readonly instruction: string; readonly isHeadless: boolean; - readonly cookieBrowserKeys: readonly string[]; + readonly cookieImportProfiles: readonly Browser[]; readonly baseUrl?: string; readonly savedFlow?: SavedFlow; readonly learnings?: string; - readonly liveViewUrl?: string; readonly testCoverage?: TestCoverageReport; readonly onConfigOptions?: (configOptions: readonly AcpConfigOption[]) => void; readonly modelPreference?: { configId: string; value: string }; readonly devServerHints?: readonly DevServerHint[]; + readonly captureFixturePath?: string; } interface ExecutorAccumState { @@ -84,6 +104,10 @@ export class Executor extends ServiceMap.Service()("@supervisor/Execut make: Effect.gen(function* () { const agent = yield* Agent; const git = yield* Git; + const artifactStore = yield* ArtifactStore; + const outputReporter = yield* OutputReporter; + const fileSystem = yield* FileSystem.FileSystem; + const planId = yield* CurrentPlanId; const gatherContext = Effect.fn("Executor.gatherContext")(function* (changesFor: ChangesFor) { yield* Effect.annotateCurrentSpan({ changesFor: changesFor._tag }); @@ -128,7 +152,7 @@ export class Executor extends ServiceMap.Service()("@supervisor/Execut instructionLength: options.instruction.length, changesFor: options.changesFor._tag, isHeadless: options.isHeadless, - cookieBrowserCount: options.cookieBrowserKeys.length, + cookieBrowserCount: options.cookieImportProfiles.length, }); const context = yield* gatherContext(options.changesFor); @@ -145,21 +169,13 @@ export class Executor extends ServiceMap.Service()("@supervisor/Execut diffPreview: context.diffPreview, baseUrl: options.baseUrl, isHeadless: options.isHeadless, - cookieBrowserKeys: options.cookieBrowserKeys, + cookieImportProfiles: options.cookieImportProfiles, savedFlow: options.savedFlow, learnings: options.learnings, testCoverage: options.testCoverage, devServerHints: options.devServerHints, }); - const planId = PlanId.makeUnsafe(crypto.randomUUID()); - const replayOutputPath = path.join( - process.cwd(), - EXPECT_STATE_DIR, - "replays", - `${planId}.ndjson`, - ); - const syntheticPlan = new TestPlan({ id: planId, changesFor: options.changesFor, @@ -169,7 +185,7 @@ export class Executor extends ServiceMap.Service()("@supervisor/Execut instruction: options.instruction, baseUrl: options.baseUrl ? Option.some(options.baseUrl) : Option.none(), isHeadless: options.isHeadless, - cookieBrowserKeys: options.cookieBrowserKeys, + cookieImportProfiles: options.cookieImportProfiles, testCoverage: options.testCoverage ? Option.some(options.testCoverage) : Option.none(), title: options.instruction, rationale: "Direct execution", @@ -181,20 +197,25 @@ export class Executor extends ServiceMap.Service()("@supervisor/Execut events: [new RunStarted({ plan: syntheticPlan })], }); - const mcpEnv = [{ name: EXPECT_REPLAY_OUTPUT_ENV_NAME, value: replayOutputPath }]; + yield* artifactStore.push(planId, new InitialPlan({ plan: syntheticPlan })); + + const mcpEnv: Array<{ name: string; value: string }> = []; if (options.baseUrl) { - mcpEnv.push({ name: EXPECT_BASE_URL_ENV_NAME, value: options.baseUrl }); - } - if (options.liveViewUrl) { mcpEnv.push({ - name: EXPECT_LIVE_VIEW_URL_ENV_NAME, - value: options.liveViewUrl, + name: EXPECT_BASE_URL_ENV_NAME, + value: options.baseUrl, }); } - if (options.cookieBrowserKeys.length > 0) { + if (!options.isHeadless) { + mcpEnv.push({ name: EXPECT_HEADED_ENV_NAME, value: "true" }); + } + if (options.cookieImportProfiles.length > 0) { + const profileJson = yield* encodeBrowserProfile(options.cookieImportProfiles[0]).pipe( + Effect.orDie, + ); mcpEnv.push({ - name: EXPECT_COOKIE_BROWSERS_ENV_NAME, - value: options.cookieBrowserKeys.join(","), + name: EXPECT_BROWSER_PROFILE_ENV_NAME, + value: profileJson, }); } @@ -215,37 +236,47 @@ export class Executor extends ServiceMap.Service()("@supervisor/Execut return agent.stream(streamOptions).pipe( Stream.tap((update) => { + if (update.sessionUpdate === "tool_call_update") { + const CONTEXT_LINES = 5; + const lines = pipe( + Array.fromIterable(Str.linesIterator(JSON.stringify(update, null, 2))), + Array.take(CONTEXT_LINES + 1), + Array.map((line) => ` ${line}`), + Array.join("\n"), + ); + + return Effect.logDebug(`Tool call update:\n${lines}`); + } + if (update.sessionUpdate === "tool_call") { + return Effect.logDebug(` Tool call: ${update.title}`); + } const callback = options.onConfigOptions; if (update.sessionUpdate === "config_option_update" && callback) { return Effect.sync(() => callback(update.configOptions)); } return Effect.void; }), + Stream.tap((update) => artifactStore.push(planId, new SessionUpdate({ update }))), Stream.mapAccum( - (): ExecutorAccumState => ({ - plan: initial, - allTerminalSince: undefined, - }), - (state, part) => { - const updated = state.plan.addEvent(part); - const terminalTimestamp = resolveTerminalTimestamp(updated, state.allTerminalSince); - const finalized = - terminalTimestamp !== undefined && - !updated.hasRunFinished && - Date.now() - terminalTimestamp >= ALL_STEPS_TERMINAL_GRACE_MS - ? updated.synthesizeRunFinished() - : updated; - - return [{ plan: finalized, allTerminalSince: terminalTimestamp }, [finalized]] as const; + () => initial, + (executed, part) => { + const next = executed.addEvent(part); + return [next, [next]] as const; }, ), + Stream.tap((executed) => outputReporter.onExecutedPlan(executed)), Stream.takeUntil((executed) => executed.hasRunFinished), Stream.mapError((reason) => new ExecutionError({ reason })), + Stream.ensuring(artifactStore.push(planId, new Done())), ); }, Stream.unwrap); return { execute } as const; }), }) { - static layer = Layer.effect(this)(this.make).pipe(Layer.provide(NodeServices.layer)); + static layer = Layer.effect(this)(this.make).pipe( + Layer.provide(ArtifactStore.layer), + Layer.provide(NodeServices.layer), + Layer.provide(Git.layer), + ); } diff --git a/packages/supervisor/src/github.ts b/packages/supervisor/src/github.ts index a5b791b75..0020ba1ea 100644 --- a/packages/supervisor/src/github.ts +++ b/packages/supervisor/src/github.ts @@ -2,7 +2,7 @@ import { Array as Arr, Effect, Layer, Match, Option, Schema, ServiceMap } from " import * as NodeServices from "@effect/platform-node/NodeServices"; import { ChildProcess } from "effect/unstable/process"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { join } from "node:path"; +import * as path from "node:path"; import { COMMENT_DIRECTORY_PREFIX, GITHUB_TIMEOUT_MS, PR_LIMIT } from "./constants"; import { FileSystem } from "effect/FileSystem"; import { @@ -112,7 +112,7 @@ export class Github extends ServiceMap.Service()("@supervisor/GitHub", { const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: COMMENT_DIRECTORY_PREFIX, }); - const bodyPath = join(dir, "pull-request-comment.md"); + const bodyPath = path.join(dir, "pull-request-comment.md"); yield* fileSystem.writeFileString(bodyPath, body); yield* runGhCommand(cwd, [ "pr", @@ -155,7 +155,7 @@ export class Github extends ServiceMap.Service()("@supervisor/GitHub", { const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: COMMENT_DIRECTORY_PREFIX, }); - const bodyPath = join(dir, "pull-request-comment.json"); + const bodyPath = path.join(dir, "pull-request-comment.json"); yield* fileSystem.writeFileString(bodyPath, JSON.stringify({ body })); yield* runGhCommand(cwd, [ "api", diff --git a/packages/supervisor/src/index.ts b/packages/supervisor/src/index.ts index 9319bec9d..589d7b4f7 100644 --- a/packages/supervisor/src/index.ts +++ b/packages/supervisor/src/index.ts @@ -1,6 +1,7 @@ export { Updates } from "./updates"; export { Executor, ExecutionError, type ExecuteOptions } from "./executor"; export { Reporter } from "./reporter"; +export { OutputReporter, OutputReporterHooks } from "./output-reporter"; export { AgentProvider, type ChangedFile, @@ -44,3 +45,5 @@ export { type WatchDecision, type WatchOptions, } from "./watch"; +export { ArtifactStore } from "./artifact-store"; +export { ArtifactRpcsLive } from "./rpc/artifact.rpc.layer"; diff --git a/packages/supervisor/src/output-reporter.ts b/packages/supervisor/src/output-reporter.ts new file mode 100644 index 000000000..519c469ae --- /dev/null +++ b/packages/supervisor/src/output-reporter.ts @@ -0,0 +1,308 @@ +import pc from "picocolors"; +import figures from "figures"; +import { + Cause, + Config, + Duration, + Effect, + Fiber, + FileSystem, + Layer, + Option, + Queue, + Schema, + ServiceMap, + Stdio, + Stream, +} from "effect"; +import { NodeServices } from "@effect/platform-node"; +import { + CurrentPlanId, + TestReport, + type ExecutedTestPlan, + type ExecutionEvent, +} from "@expect/shared/models"; +import { RrVideo } from "@expect/browser"; +import { Github } from "./github"; +import { GitRepoRoot } from "./git/git"; +import { EXPECT_STATE_DIR, ARTIFACTS_DIRECTORY_NAME } from "./constants"; + +const ghaEscape = (text: string) => text.replace(/\r?\n/g, " ").replace(/::/g, ": :"); + +const formatElapsed = (ms: number) => Duration.format(Duration.millis(ms)); + +const COMMENT_MARKER = ""; + +export class OutputReporterHooks extends ServiceMap.Service< + OutputReporterHooks, + { + readonly onStepFailed: (stepId: string, message: string) => Effect.Effect; + readonly onGroupOpen: () => Effect.Effect; + readonly onGroupClose: () => Effect.Effect; + readonly onReportComplete: (report: TestReport) => Effect.Effect; + readonly onTimeout: (message: string) => Effect.Effect; + } +>()("@supervisor/OutputReporterHooks") { + static layerNoop = Layer.succeed(this, { + onStepFailed: () => Effect.void, + onGroupOpen: () => Effect.void, + onGroupClose: () => Effect.void, + onReportComplete: () => Effect.void, + onTimeout: () => Effect.void, + }); + + static layerGitHubActions = Layer.effect(this)( + Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + const fileSystem = yield* FileSystem.FileSystem; + const github = yield* Github; + const rrvideo = yield* RrVideo; + const planId = yield* CurrentPlanId; + const repoRoot = yield* GitRepoRoot; + const writeStdout = (text: string) => + Stream.make(text + "\n") + .pipe(Stream.run(stdio.stdout())) + .pipe(Effect.orDie); + + const replayPath = `${repoRoot}/${EXPECT_STATE_DIR}/${ARTIFACTS_DIRECTORY_NAME}/${planId}.ndjson`; + const githubOutputPath = yield* Config.string("GITHUB_OUTPUT").pipe(Config.option); + const summaryPath = yield* Config.string("GITHUB_STEP_SUMMARY").pipe(Config.option); + + return { + onStepFailed: (stepId: string, message: string) => + writeStdout(`::error title=${ghaEscape(stepId)} failed::${ghaEscape(message)}`), + onGroupOpen: () => writeStdout("::group::expect test execution"), + onGroupClose: () => writeStdout("::endgroup::"), + onReportComplete: (report: TestReport) => + Effect.gen(function* () { + if (Option.isSome(githubOutputPath)) { + yield* fileSystem.writeFileString( + githubOutputPath.value, + `result=${report.status}\n`, + { flag: "a" }, + ); + } + + if (Option.isSome(summaryPath)) { + yield* fileSystem.writeFileString(summaryPath.value, report.toGithubStepSummary, { + flag: "a", + }); + } + + const videoPath = yield* rrvideo.convert({ + inputPath: replayPath.replace(/\.ndjson$/, "-latest.json"), + outputPath: replayPath.replace(/\.ndjson$/, ".mp4"), + skipInactive: true, + speed: 1, + }); + if (Option.isSome(githubOutputPath) && videoPath) { + yield* fileSystem.writeFileString( + githubOutputPath.value, + `video_path=${videoPath}\n`, + { flag: "a" }, + ); + } + + const cwd = process.cwd(); + const currentBranch = report.currentBranch; + if (currentBranch) { + const pullRequest = yield* github.findPullRequest(cwd, { + _tag: "Branch", + branchName: currentBranch, + }); + if (Option.isSome(pullRequest)) { + yield* github.upsertComment( + cwd, + pullRequest.value, + COMMENT_MARKER, + report.toGithubComment, + ); + } + } + }).pipe( + Effect.catchTags({ + GitHubCommandError: Effect.die, + PlatformError: Effect.die, + RrVideoConvertError: Effect.die, + }), + ), + onTimeout: (message: string) => + writeStdout(`::error title=Execution timed out::${ghaEscape(message)}`), + }; + }), + ).pipe( + Layer.provide(Github.layer), + Layer.provide(RrVideo.layer), + Layer.provide(NodeServices.layer), + ); +} + +export class OutputReporter extends ServiceMap.Service< + OutputReporter, + { + readonly onExecutedPlan: (executed: ExecutedTestPlan) => Effect.Effect; + readonly onComplete: (report: TestReport) => Effect.Effect; + readonly onTimeout: (timeoutMs: number) => Effect.Effect; + } +>()("@supervisor/OutputReporter") { + static layerNoop = Layer.succeed(this, { + onExecutedPlan: () => Effect.void, + onComplete: () => Effect.void, + onTimeout: () => Effect.void, + }); + + static layerStdout = (options: { agent: string; timeoutMs: number | undefined }) => + Layer.effect(OutputReporter)( + Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + const hooks = yield* OutputReporterHooks; + const write = (text: string) => + Stream.make(text).pipe(Stream.run(stdio.stdout())).pipe(Effect.orDie); + const seenEvents = new Set(); + + const timeoutLabel = + options.timeoutMs !== undefined ? ` · timeout ${formatElapsed(options.timeoutMs)}` : ""; + + yield* write(""); + yield* write( + ` ${pc.bold(pc.cyan("expect"))} ${pc.dim("CI")} · ${pc.dim( + options.agent, + )}${pc.dim(timeoutLabel)}`, + ); + yield* hooks.onGroupOpen(); + + return { + onExecutedPlan: (executed: ExecutedTestPlan) => + Effect.gen(function* () { + for (const event of executed.events) { + if (seenEvents.has(event.id)) continue; + seenEvents.add(event.id); + yield* printEvent(write, event, executed); + if (event._tag === "StepFailed") { + yield* hooks.onStepFailed(event.stepId, event.message); + } + } + }), + onComplete: (report: TestReport) => + Effect.gen(function* () { + yield* hooks.onGroupClose(); + yield* printSummary(write, report); + yield* hooks.onReportComplete(report); + }), + onTimeout: (timeoutMs: number) => + Effect.gen(function* () { + yield* hooks.onGroupClose(); + yield* write(""); + const message = `Execution timed out after ${formatElapsed(timeoutMs)}`; + yield* write( + ` ${pc.red(figures.cross)} ${pc.red(pc.bold("Timeout"))} ${pc.red(message)}`, + ); + yield* hooks.onTimeout(message); + }), + }; + }), + ).pipe(Layer.provide(NodeServices.layer)); + + static layerStdoutNoop = (options: { agent: string; timeoutMs: number | undefined }) => + this.layerStdout(options).pipe(Layer.provideMerge(OutputReporterHooks.layerNoop)); + + static layerGitHubActions = (options: { agent: string; timeoutMs: number | undefined }) => + OutputReporter.layerStdout(options).pipe( + Layer.provideMerge(OutputReporterHooks.layerGitHubActions), + ); + + static layerJson = Layer.effect(OutputReporter)( + Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + const writeStdout = (text: string) => + Stream.make(text).pipe(Stream.run(stdio.stdout())).pipe(Effect.orDie); + + return { + onExecutedPlan: () => Effect.void, + onComplete: (report: TestReport) => + Effect.gen(function* () { + const encoded = yield* Schema.encodeEffect(TestReport.json)( + new TestReport({ + ...report, + diffPreview: "", + events: [], + }), + ).pipe(Effect.orDie); + yield* writeStdout(encoded); + }), + onTimeout: (timeoutMs: number) => + writeStdout( + JSON.stringify( + { status: "failed", summary: `Timed out after ${timeoutMs}ms` }, + undefined, + 2, + ), + ), + }; + }), + ).pipe(Layer.provide(NodeServices.layer)); +} + +const printEvent = ( + write: (text: string) => Effect.Effect, + event: ExecutionEvent, + executed: ExecutedTestPlan, +): Effect.Effect => { + switch (event._tag) { + case "RunStarted": { + const baseUrl = Option.isSome(event.plan.baseUrl) ? event.plan.baseUrl.value : undefined; + return Effect.gen(function* () { + yield* write(""); + yield* write(` ${pc.bold(event.plan.title)}`); + if (baseUrl) { + yield* write(` ${pc.dim(baseUrl)}`); + } + }); + } + case "StepStarted": + return write(` ${pc.dim(figures.circle)} ${pc.dim(event.title)}`); + case "StepCompleted": { + const step = executed.steps.find((step) => step.id === event.stepId); + const timeLabel = + step?.elapsedMs !== undefined ? ` ${pc.dim(`(${formatElapsed(step.elapsedMs)})`)}` : ""; + return write(` ${pc.green(figures.tick)} ${event.summary}${timeLabel}`); + } + case "StepFailed": { + const failedStep = executed.steps.find((step) => step.id === event.stepId); + const failedTitle = failedStep?.title ?? event.stepId; + const timeLabel = + failedStep?.elapsedMs !== undefined + ? ` ${pc.dim(`(${formatElapsed(failedStep.elapsedMs)})`)}` + : ""; + return Effect.gen(function* () { + yield* write(` ${pc.red(figures.cross)} ${failedTitle}${timeLabel}`); + yield* write(` ${pc.red(event.message)}`); + }); + } + case "StepSkipped": { + const skippedStep = executed.steps.find((step) => step.id === event.stepId); + const skippedTitle = skippedStep?.title ?? event.stepId; + return Effect.gen(function* () { + yield* write(` ${pc.yellow(figures.arrowRight)} ${skippedTitle} ${pc.yellow("[skipped]")}`); + if (event.reason) { + yield* write(` ${pc.dim(event.reason)}`); + } + }); + } + default: + return Effect.void; + } +}; + +const printSummary = (write: (text: string) => Effect.Effect, report: TestReport) => + Effect.gen(function* () { + yield* write(""); + const parts: string[] = []; + if (report.passedStepCount > 0) parts.push(pc.green(`${report.passedStepCount} passed`)); + if (report.failedStepCount > 0) parts.push(pc.red(`${report.failedStepCount} failed`)); + if (report.skippedStepCount > 0) parts.push(pc.yellow(`${report.skippedStepCount} skipped`)); + yield* write( + ` ${pc.bold("Tests")} ${parts.join(pc.dim(" | "))} ${pc.dim(`(${report.steps.length})`)}`, + ); + yield* write(` ${pc.bold("Time")} ${formatElapsed(report.totalDurationMs)}`); + }); diff --git a/packages/supervisor/src/reporter.ts b/packages/supervisor/src/reporter.ts index d0a698834..aa5bc1a2c 100644 --- a/packages/supervisor/src/reporter.ts +++ b/packages/supervisor/src/reporter.ts @@ -1,8 +1,22 @@ -import { Effect, Layer, Option, ServiceMap } from "effect"; -import { type ExecutedTestPlan, TestReport } from "@expect/shared/models"; +import { Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap, Stream } from "effect"; +import { Artifact, type ExecutedTestPlan, RrwebEvent, TestReport } from "@expect/shared/models"; +import { RrVideo } from "@expect/browser"; +import { GitRepoRoot } from "./git/git"; +import { EXPECT_STATE_DIR } from "./constants"; +import { NodeServices } from "@effect/platform-node"; +import { Ndjson } from "effect/unstable/encoding"; + +export interface ExportVideoOptions { + exportPathOverride?: string; +} export class Reporter extends ServiceMap.Service()("@supervisor/Reporter", { make: Effect.gen(function* () { + const rrvideo = yield* RrVideo; + const fs = yield* FileSystem.FileSystem; + const repoRoot = yield* GitRepoRoot; + const path = yield* Path.Path; + const report = Effect.fn("Reporter.report")(function* (executed: ExecutedTestPlan) { const failedSteps = executed.events.filter((event) => event._tag === "StepFailed"); const completedSteps = executed.events.filter((event) => event._tag === "StepCompleted"); @@ -11,7 +25,9 @@ export class Reporter extends ServiceMap.Service()("@supervisor/Report const summary = runFinished ? runFinished.summary : failedSteps.length > 0 - ? `${failedSteps.length} step${failedSteps.length === 1 ? "" : "s"} failed, ${completedSteps.length} passed` + ? `${failedSteps.length} step${ + failedSteps.length === 1 ? "" : "s" + } failed, ${completedSteps.length} passed` : `${completedSteps.length} step${completedSteps.length === 1 ? "" : "s"} completed`; const screenshotPaths = executed.events @@ -43,8 +59,39 @@ export class Reporter extends ServiceMap.Service()("@supervisor/Report return report; }); - return { report } as const; + const exportVideo = Effect.fn("Reporter.exportVideo")(function* ( + report: TestReport, + options?: ExportVideoOptions, + ) { + yield* Effect.logInfo(`Generating a video for report for test "${report.title}"`); + + const events = yield* fs + .stream(path.join(repoRoot, EXPECT_STATE_DIR, `artifacts`, `${report.id}.ndjson`)) + .pipe( + Stream.pipeThroughChannel( + Ndjson.decodeSchema(Schema.toCodecJson(Artifact))({ + ignoreEmptyLines: true, + }), + ), + Stream.filter((a): a is RrwebEvent => a._tag === "RrwebEvent"), + Stream.map((a) => a.event), + Stream.runCollect, + ); + + yield* rrvideo.convertEvents({ + events: events as any[], + outputPath: + options?.exportPathOverride ?? + path.join(repoRoot, EXPECT_STATE_DIR, `videos`, `${report.id}.mp4`), + }); + return report; + }); + + return { report, exportVideo } as const; }), }) { - static layer = Layer.effect(this)(this.make); + static layer = Layer.effect(this)(this.make).pipe( + Layer.provide(NodeServices.layer), + Layer.provide(RrVideo.layer), + ); } diff --git a/packages/supervisor/src/rpc/artifact.rpc.layer.ts b/packages/supervisor/src/rpc/artifact.rpc.layer.ts new file mode 100644 index 000000000..7f627a96a --- /dev/null +++ b/packages/supervisor/src/rpc/artifact.rpc.layer.ts @@ -0,0 +1,33 @@ +import { Effect, Layer, Stream } from "effect"; +import { ArtifactRpcs } from "@expect/shared/rpcs"; +import { ArtifactStore } from "../artifact-store"; + +export const ArtifactRpcsLive = ArtifactRpcs.toLayer( + Effect.gen(function* () { + const artifactStore = yield* ArtifactStore; + + return ArtifactRpcs.of({ + "artifact.PushArtifacts": (request) => + Effect.forEach(request.batch, (artifact) => + artifactStore.push(request.planId, artifact), + ).pipe( + Effect.tap(() => + Effect.logDebug( + `Artifacts pushed. plan ID: ${request.planId}, (${request.batch + .map((a) => a._tag) + .join(", ")})`, + ), + ), + Effect.asVoid, + ), + "artifact.StreamEvents": (request) => { + return artifactStore.stream(request.planId); + }, + "artifact.GetAllArtifacts": (request) => artifactStore.readAll(request.planId), + "artifact.ListTests": () => + artifactStore + .listTests() + .pipe(Effect.tap((tests) => Effect.logDebug(`Listed ${tests.length} tests`))), + }); + }), +).pipe(Layer.provide(ArtifactStore.layer)); diff --git a/packages/supervisor/src/tail.ts b/packages/supervisor/src/tail.ts new file mode 100644 index 000000000..7f01cc7e9 --- /dev/null +++ b/packages/supervisor/src/tail.ts @@ -0,0 +1,46 @@ +import { Effect, FileSystem, Layer, ServiceMap, Stream } from "effect"; +import { NodeServices } from "@effect/platform-node"; + +export class Tail extends ServiceMap.Service()("@supervisor/Tail", { + make: Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + const stream = (filePath: string) => { + let offset = 0; + + const readNewBytes = Effect.gen(function* () { + const stat = yield* fileSystem.stat(filePath).pipe(Effect.orDie); + const size = Number(stat.size); + if (size <= offset) return new Uint8Array(0); + const chunks = yield* fileSystem + .stream(filePath, { offset, bytesToRead: size - offset }) + .pipe(Stream.runCollect, Effect.orDie); + offset = size; + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const bytes = new Uint8Array(totalLength); + let pos = 0; + for (const chunk of chunks) { + bytes.set(chunk, pos); + pos += chunk.length; + } + return bytes; + }); + + const initialRead = Stream.fromEffect(readNewBytes).pipe( + Stream.filter((bytes) => bytes.length > 0), + ); + + const watchReads = fileSystem.watch(filePath).pipe( + Stream.filter((event) => event._tag === "Update"), + Stream.mapEffect(() => readNewBytes), + Stream.filter((bytes) => bytes.length > 0), + ); + + return Stream.concat(initialRead, watchReads); + }; + + return { stream } as const; + }), +}) { + static layer = Layer.effect(this)(this.make).pipe(Layer.provide(NodeServices.layer)); +} diff --git a/packages/supervisor/src/watch.ts b/packages/supervisor/src/watch.ts index 5ece82a1e..989a28215 100644 --- a/packages/supervisor/src/watch.ts +++ b/packages/supervisor/src/watch.ts @@ -1,5 +1,6 @@ import { Data, Effect, Layer, Option, Ref, Schedule, Schema, ServiceMap, Stream } from "effect"; import { Agent, AgentStreamOptions } from "@expect/agent"; +import type { Browser } from "@expect/cookies"; import { AcpAgentMessageChunk, type ChangedFile, @@ -64,7 +65,10 @@ export type WatchEvent = Data.TaggedEnum<{ Assessing: {}; RunStarting: { readonly fingerprint: string }; RunUpdate: { readonly executedPlan: ExecutedTestPlan }; - RunCompleted: { readonly executedPlan: ExecutedTestPlan; readonly fingerprint: string }; + RunCompleted: { + readonly executedPlan: ExecutedTestPlan; + readonly fingerprint: string; + }; Skipped: { readonly fingerprint: string }; Error: { readonly error: unknown }; Stopped: {}; @@ -90,7 +94,7 @@ export interface WatchOptions { readonly changesFor: ChangesFor; readonly instruction: string; readonly isHeadless: boolean; - readonly cookieBrowserKeys: readonly string[]; + readonly cookieImportProfiles: readonly Browser[]; readonly baseUrl?: string; readonly onEvent: (event: WatchEvent) => void; } @@ -124,7 +128,11 @@ export class Watch extends ServiceMap.Service()("@supervisor/Watch", { instruction: string, ) { const repoRoot = yield* GitRepoRoot; - const prompt = buildWatchAssessmentPrompt({ diffPreview, changedFiles, instruction }); + const prompt = buildWatchAssessmentPrompt({ + diffPreview, + changedFiles, + instruction, + }); const streamOptions = new AgentStreamOptions({ cwd: repoRoot, @@ -258,7 +266,7 @@ export class Watch extends ServiceMap.Service()("@supervisor/Watch", { changesFor: options.changesFor, instruction: options.instruction, isHeadless: options.isHeadless, - cookieBrowserKeys: options.cookieBrowserKeys, + cookieImportProfiles: options.cookieImportProfiles, baseUrl: options.baseUrl, }; @@ -308,5 +316,8 @@ export class Watch extends ServiceMap.Service()("@supervisor/Watch", { return { assess, run } as const; }), }) { - static layer = Layer.effect(this)(this.make); + static layer = Layer.effect(this)(this.make).pipe( + Layer.provide(Git.layer), + Layer.provide(Executor.layer), + ); } diff --git a/packages/supervisor/tests/artifact-store.test.ts b/packages/supervisor/tests/artifact-store.test.ts new file mode 100644 index 000000000..4eb0cc00b --- /dev/null +++ b/packages/supervisor/tests/artifact-store.test.ts @@ -0,0 +1,217 @@ +import { describe, it, assert } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Sink, Stream } from "effect"; +import { + type Artifact, + Done, + InitialPlan, + PlanId, + TestPlan, + ChangesFor, + RrwebEvent, +} from "@expect/shared/models"; +import { ArtifactStore } from "../src/artifact-store"; +import { Tail } from "../src/tail"; +import { GitRepoRoot } from "../src/git/git"; + +const makePlan = (id: string, title: string): TestPlan => + new TestPlan({ + id: PlanId.makeUnsafe(id), + title, + rationale: "test", + steps: [], + changesFor: ChangesFor.makeUnsafe({ _tag: "WorkingTree" }), + currentBranch: "main", + diffPreview: "", + fileStats: [], + instruction: title, + baseUrl: Option.none(), + isHeadless: false, + cookieImportProfiles: [], + testCoverage: Option.none(), + }); + +const makeRrwebEvents = (count: number): Artifact[] => + Array.from( + { length: count }, + (_, index) => new RrwebEvent({ event: { type: 0, timestamp: index } }), + ); + +const makeTestLayer = () => { + const files = new Map(); + + const mockFs = Layer.mock(FileSystem.FileSystem, { + sink: () => Sink.drain, + "~effect/platform/FileSystem": "~effect/platform/FileSystem", + makeDirectory: () => Effect.void, + exists: (filePath: string) => Effect.succeed(files.has(filePath)), + writeFileString: (filePath: string, content: string, options) => + Effect.sync(() => { + const data = new TextEncoder().encode(content); + if (options?.flag === "a") { + const existing = files.get(filePath) ?? new Uint8Array(0); + const merged = new Uint8Array(existing.length + data.length); + merged.set(existing); + merged.set(data, existing.length); + files.set(filePath, merged); + } else { + files.set(filePath, data); + } + }), + readDirectory: (dirPath: string) => + Effect.sync(() => + [...files.keys()] + .filter((key) => key.startsWith(dirPath + "/")) + .map((key) => key.slice(dirPath.length + 1)) + .filter((name) => !name.includes("/")), + ), + stream: (filePath: string, options) => { + const content = files.get(filePath); + if (!content) return Stream.die(new Error(`File not found: ${filePath}`)); + const offset = Number(options?.offset ?? 0); + const bytesToRead = options?.bytesToRead + ? Number(options.bytesToRead) + : content.length - offset; + return Stream.make(content.slice(offset, offset + bytesToRead)); + }, + stat: (filePath: string) => + Effect.sync(() => ({ + type: "File", + size: FileSystem.Size(files.get(filePath)?.length ?? 0), + mtime: Option.none(), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0o644, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + blksize: Option.none(), + blocks: Option.none(), + })), + } satisfies Partial); + + const tailLayer = Layer.effect(Tail)(Tail.make).pipe(Layer.provide(mockFs)); + + const layer = Layer.effect(ArtifactStore)(ArtifactStore.make).pipe( + Layer.provide(Layer.succeed(GitRepoRoot, "/test-repo")), + Layer.provide(tailLayer), + Layer.provide(mockFs), + ); + + return { layer, files }; +}; + +describe("ArtifactStore", () => { + it.effect("push writes InitialPlan to ndjson file", () => { + const { layer, files } = makeTestLayer(); + return Effect.gen(function* () { + const artifactStore = yield* ArtifactStore; + const plan = makePlan("plan-001", "Test plan one"); + yield* artifactStore.push(plan.id, new InitialPlan({ plan })); + + const ndjsonPath = "/test-repo/.expect/artifacts/plan-001.ndjson"; + assert.isTrue(files.has(ndjsonPath)); + const content = new TextDecoder().decode(files.get(ndjsonPath)!); + const parsed = JSON.parse(content.trim()); + assert.strictEqual(parsed._tag, "InitialPlan"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("push appends multiple artifacts to ndjson file", () => { + const { layer, files } = makeTestLayer(); + return Effect.gen(function* () { + const artifactStore = yield* ArtifactStore; + const plan = makePlan("plan-002", "Multi push"); + yield* artifactStore.push(plan.id, new InitialPlan({ plan })); + for (const event of makeRrwebEvents(5)) { + yield* artifactStore.push(plan.id, event); + } + + const ndjsonPath = "/test-repo/.expect/artifacts/plan-002.ndjson"; + const content = new TextDecoder().decode(files.get(ndjsonPath)!); + const lines = content.trim().split("\n"); + assert.strictEqual(lines.length, 6); + + const parsed = lines.map((line) => JSON.parse(line)); + assert.strictEqual(parsed[0]._tag, "InitialPlan"); + assert.strictEqual(parsed[1]._tag, "RrwebEvent"); + assert.strictEqual(parsed[5]._tag, "RrwebEvent"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("stream reads artifacts from file", () => { + const { layer } = makeTestLayer(); + return Effect.gen(function* () { + const artifactStore = yield* ArtifactStore; + const plan = makePlan("plan-003", "Stream test"); + yield* artifactStore.push(plan.id, new InitialPlan({ plan })); + for (const event of makeRrwebEvents(3)) { + yield* artifactStore.push(plan.id, event); + } + yield* artifactStore.push(plan.id, new Done()); + + const artifacts = yield* artifactStore.stream(plan.id).pipe(Stream.runCollect); + + assert.strictEqual(artifacts.length, 4); + assert.strictEqual(artifacts[0]._tag, "InitialPlan"); + assert.strictEqual(artifacts[1]._tag, "RrwebEvent"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("listTests returns plans from disk", () => { + const { layer } = makeTestLayer(); + return Effect.gen(function* () { + const artifactStore = yield* ArtifactStore; + yield* artifactStore.push( + PlanId.makeUnsafe("plan-a"), + new InitialPlan({ plan: makePlan("plan-a", "Plan A") }), + ); + yield* artifactStore.push( + PlanId.makeUnsafe("plan-b"), + new InitialPlan({ plan: makePlan("plan-b", "Plan B") }), + ); + + const tests = yield* artifactStore.listTests(); + const ids = tests.map((test) => test.id as string); + assert.isTrue(ids.includes("plan-a")); + assert.isTrue(ids.includes("plan-b")); + }).pipe(Effect.provide(layer)); + }); + + it.effect("listTests round-trips TestPlan through ndjson", () => { + const { layer } = makeTestLayer(); + return Effect.gen(function* () { + const artifactStore = yield* ArtifactStore; + const plan = makePlan("plan-rt", "Round trip"); + yield* artifactStore.push(plan.id, new InitialPlan({ plan })); + + const tests = yield* artifactStore.listTests(); + assert.strictEqual(tests.length, 1); + assert.strictEqual(tests[0].title, "Round trip"); + assert.strictEqual(tests[0].id, plan.id); + }).pipe(Effect.provide(layer)); + }); + + it.effect("stream replays all pushed events", () => { + const { layer } = makeTestLayer(); + return Effect.gen(function* () { + const artifactStore = yield* ArtifactStore; + const plan = makePlan("plan-replay", "Replay test"); + yield* artifactStore.push(plan.id, new InitialPlan({ plan })); + for (const event of makeRrwebEvents(3)) { + yield* artifactStore.push(plan.id, event); + } + yield* artifactStore.push(plan.id, new Done()); + + const artifacts = yield* artifactStore.stream(plan.id).pipe(Stream.runCollect); + + assert.strictEqual(artifacts.length, 4); + assert.strictEqual(artifacts[0]._tag, "InitialPlan"); + assert.strictEqual(artifacts[1]._tag, "RrwebEvent"); + assert.strictEqual(artifacts[2]._tag, "RrwebEvent"); + assert.strictEqual(artifacts[3]._tag, "RrwebEvent"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/packages/supervisor/tests/detect-project.test.ts b/packages/supervisor/tests/detect-project.test.ts index 62a6e4ce6..9b4fc37ed 100644 --- a/packages/supervisor/tests/detect-project.test.ts +++ b/packages/supervisor/tests/detect-project.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { Effect } from "effect"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { tmpdir } from "node:os"; import { detectProject } from "../src/detect-project"; @@ -12,19 +12,19 @@ const run = (projectRoot: string) => let tempDir: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "detect-project-")); + tempDir = fs.mkdtempSync(path.join(tmpdir(), "detect-project-")); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); const writePackageJson = (content: Record) => { - writeFileSync(join(tempDir, "package.json"), JSON.stringify(content)); + fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify(content)); }; const writeFile = (relativePath: string, content: string) => { - writeFileSync(join(tempDir, relativePath), content); + fs.writeFileSync(path.join(tempDir, relativePath), content); }; describe("detectProject", () => { diff --git a/packages/supervisor/tests/executor-e2e.test.ts b/packages/supervisor/tests/executor-e2e.test.ts new file mode 100644 index 000000000..4172c0bf3 --- /dev/null +++ b/packages/supervisor/tests/executor-e2e.test.ts @@ -0,0 +1,176 @@ +import * as path from "node:path"; +import { describe, it, assert } from "@effect/vitest"; +import { Effect, Layer, Option, Stream } from "effect"; +import { NodeServices } from "@effect/platform-node"; +import { CurrentPlanId, ExecutedTestPlan, PlanId, ChangesFor } from "@expect/shared/models"; +import { Agent } from "@expect/agent"; +import { RrVideo } from "@expect/browser"; +import { Executor } from "../src/executor"; +import { ArtifactStore } from "../src/artifact-store"; +import { OutputReporter } from "../src/output-reporter"; +import { Reporter } from "../src/reporter"; +import { Git, GitRepoRoot } from "../src/git/git"; +import * as fs from "node:fs"; + +const FIXTURE_PATH = path.join(import.meta.dirname, "fixtures", "skosh-view-counter.ndjson"); + +const fixtureExists = fs.existsSync(FIXTURE_PATH); + +const mockGitLayer = Layer.mock(Git, { + withRepoRoot: () => (effect) => effect as any, + getCurrentBranch: Effect.succeed("main"), + getMainBranch: Effect.succeed("main"), + isInsideWorkTree: Effect.succeed(true), + getFileStats: () => Effect.succeed([]), + getChangedFiles: () => Effect.succeed([]), + getDiffPreview: () => Effect.succeed(""), + getRecentCommits: () => Effect.succeed([]), + getCommitSummary: () => Effect.succeed({ hash: "", shortHash: "", subject: "" }), + getState: () => + Effect.succeed({ + isGitRepo: true, + hasUntestedChanges: false, + workingTreeFileStats: [], + }) as any, + computeFingerprint: () => Effect.succeed(""), + saveTestedFingerprint: () => Effect.void, +}); + +const makeE2eLayer = () => { + const tmpDir = path.join(import.meta.dirname, ".test-output"); + + const artifactStoreLayer = ArtifactStore.layer.pipe( + Layer.provide(Layer.succeed(GitRepoRoot, tmpDir)), + ); + + const planId = PlanId.makeUnsafe(crypto.randomUUID()); + + return Layer.mergeAll(Executor.layer, Reporter.layer).pipe( + Layer.provideMerge(artifactStoreLayer), + Layer.provideMerge(OutputReporter.layerStdoutNoop({ agent: "claude", timeoutMs: undefined })), + Layer.provide(Agent.layerTest(FIXTURE_PATH)), + Layer.provide(mockGitLayer), + Layer.provideMerge(RrVideo.layer), + Layer.provideMerge(Layer.succeed(GitRepoRoot, tmpDir)), + Layer.provideMerge(Layer.succeed(CurrentPlanId, planId)), + Layer.provide(NodeServices.layer), + ); +}; + +describe("Executor e2e (fixture replay)", () => { + it.effect("executes a full test plan from fixture and produces events", () => { + const layer = makeE2eLayer(); + return Effect.gen(function* () { + const executor = yield* Executor; + const executedPlans: ExecutedTestPlan[] = []; + + const finalExecuted = yield* executor + .execute({ + changesFor: ChangesFor.makeUnsafe({ _tag: "WorkingTree" }), + instruction: + "Navigate to https://skosh.dev and verify the view counter increases on refresh", + isHeadless: true, + cookieImportProfiles: [], + }) + .pipe( + Stream.tap((executed) => + Effect.sync(() => { + executedPlans.push(executed); + }), + ), + Stream.runLast, + Effect.map((option) => (option._tag === "Some" ? option.value : undefined)), + ); + + assert.isDefined(finalExecuted); + assert.isTrue(executedPlans.length > 0, "should have received at least one update"); + assert.isTrue(finalExecuted!.events.length > 0, "final plan should have events"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("pushed events are readable via ArtifactStore.stream", () => { + const layer = makeE2eLayer(); + return Effect.gen(function* () { + const executor = yield* Executor; + const artifactStore = yield* ArtifactStore; + + let planId: string | undefined; + + yield* executor + .execute({ + changesFor: ChangesFor.makeUnsafe({ _tag: "WorkingTree" }), + instruction: + "Navigate to https://skosh.dev and verify the view counter increases on refresh", + isHeadless: true, + cookieImportProfiles: [], + }) + .pipe( + Stream.tap((executed) => + Effect.sync(() => { + planId = executed.id; + }), + ), + Stream.runDrain, + ); + + assert.isDefined(planId); + + const payloads = yield* artifactStore + .stream(PlanId.makeUnsafe(planId!)) + .pipe(Stream.runCollect); + + assert.isTrue(payloads.length > 0, "should read payloads from stream"); + assert.strictEqual(payloads[0]._tag, "InitialPlan"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("pushed events appear in listTests", () => { + const layer = makeE2eLayer(); + return Effect.gen(function* () { + const executor = yield* Executor; + const artifactStore = yield* ArtifactStore; + + yield* executor + .execute({ + changesFor: ChangesFor.makeUnsafe({ _tag: "WorkingTree" }), + instruction: + "Navigate to https://skosh.dev and verify the view counter increases on refresh", + isHeadless: true, + cookieImportProfiles: [], + }) + .pipe(Stream.runDrain); + + const tests = yield* artifactStore.listTests(); + assert.isTrue(tests.length > 0, "should have at least one test listed"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("executes, generates report, and exports video", () => { + const layer = makeE2eLayer(); + return Effect.gen(function* () { + const executor = yield* Executor; + const reporter = yield* Reporter; + + const finalExecuted = yield* executor + .execute({ + changesFor: ChangesFor.makeUnsafe({ _tag: "WorkingTree" }), + instruction: + "Navigate to https://skosh.dev and verify the view counter increases on refresh", + isHeadless: true, + cookieImportProfiles: [], + }) + .pipe( + Stream.runLast, + Effect.flatMap((option) => option.asEffect()), + ); + + const report = yield* reporter.report(finalExecuted); + + assert.isDefined(report); + assert.isTrue(report.steps.length > 0, "report should have steps"); + assert.isTrue(report.summary.length > 0, "report should have a summary"); + + yield* reporter.exportVideo(report); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/packages/supervisor/tests/executor.test.ts b/packages/supervisor/tests/executor.test.ts index 858e4cf12..6825d4b74 100644 --- a/packages/supervisor/tests/executor.test.ts +++ b/packages/supervisor/tests/executor.test.ts @@ -35,7 +35,7 @@ const makeTestPlan = (): TestPlan => instruction: "test", baseUrl: Option.none(), isHeadless: false, - cookieBrowserKeys: [], + cookieImportProfiles: [], testCoverage: Option.none(), } as any); @@ -68,7 +68,7 @@ const fixtureUpdates = [ status: "completed", rawOutput: { content: "{ ... }" }, }, -].map((update) => decode(update)); +].map((update) => decode(update as typeof AcpSessionUpdate.Type)); describe("reducer", () => { it("reduces AcpSessionUpdates into ExecutedTestPlan", () => { diff --git a/packages/supervisor/tsconfig.json b/packages/supervisor/tsconfig.json index 57bf531c0..74ca7f8f2 100644 --- a/packages/supervisor/tsconfig.json +++ b/packages/supervisor/tsconfig.json @@ -2,8 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src", "types": ["node"] }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/packages/typescript-sdk/src/expect.ts b/packages/typescript-sdk/src/expect.ts index af44bb022..8f67c6523 100644 --- a/packages/typescript-sdk/src/expect.ts +++ b/packages/typescript-sdk/src/expect.ts @@ -1,7 +1,13 @@ -import { Effect, Option, Stream } from "effect"; +import { Effect, Layer, Option, Stream } from "effect"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Executor, type ExecuteOptions } from "@expect/supervisor"; -import { type ExecutedTestPlan, type ExecutionEvent, ChangesFor } from "@expect/shared/models"; +import { + type ExecutedTestPlan, + type ExecutionEvent, + ChangesFor, + CurrentPlanId, + PlanId, +} from "@expect/shared/models"; import { Cookies as CookiesService, Browsers, @@ -278,7 +284,10 @@ const runExecution = ( after?: TestInput["after"]; page?: Page; }, -): { promise: Promise; subscribe: () => AsyncIterableIterator } => { +): { + promise: Promise; + subscribe: () => AsyncIterableIterator; +} => { const config = getGlobalConfig(); const timeoutMs = input.timeout ?? config.timeout ?? DEFAULT_TIMEOUT_MS; const isHeadless = (input.mode ?? config.mode ?? "headless") === "headless"; @@ -286,7 +295,9 @@ const runExecution = ( const rootDir = config.rootDir ?? process.cwd(); const eventBuffer: TestEvent[] = []; - const resolveWaiter: { current: (() => void) | undefined } = { current: undefined }; + const resolveWaiter: { current: (() => void) | undefined } = { + current: undefined, + }; let finished = false; let executionError: unknown; @@ -311,7 +322,8 @@ const runExecution = ( changesFor: ChangesFor.makeUnsafe({ _tag: "WorkingTree" }), instruction, isHeadless, - cookieBrowserKeys: [...resolved.browserKeys], + // @todo(Aiden): add back, we have to pass in the specific profile we wanna use + cookieImportProfiles: [], baseUrl: url, }; diff --git a/packages/typescript-sdk/src/layers.ts b/packages/typescript-sdk/src/layers.ts index affcd683e..ca82b2713 100644 --- a/packages/typescript-sdk/src/layers.ts +++ b/packages/typescript-sdk/src/layers.ts @@ -1,6 +1,7 @@ import { Layer, References } from "effect"; -import { Executor, Git } from "@expect/supervisor"; +import { Executor, Git, OutputReporter } from "@expect/supervisor"; import { Agent, type AgentBackend } from "@expect/agent"; +import { CurrentPlanId, PlanId } from "@expect/shared/models"; export const layerSdk = (agentBackend: AgentBackend, rootDir: string) => { const gitLayer = Git.withRepoRoot(rootDir); @@ -10,5 +11,7 @@ export const layerSdk = (agentBackend: AgentBackend, rootDir: string) => { return Layer.mergeAll(executorLayer, gitLayer).pipe( Layer.provideMerge(agentLayer), Layer.provideMerge(Layer.succeed(References.MinimumLogLevel, "Error")), + Layer.provide(Layer.succeed(CurrentPlanId, PlanId.makeUnsafe(crypto.randomUUID()))), + Layer.provide(OutputReporter.layerNoop), ); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c77306008..9b7a3f73b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,12 +15,21 @@ importers: '@changesets/cli': specifier: ^2.27.0 version: 2.30.0(@types/node@22.19.15) + '@effect/platform-node': + specifier: 4.0.0-beta.35 + version: 4.0.0-beta.35(effect@4.0.0-beta.35)(ioredis@5.10.0) + '@expect/shared': + specifier: workspace:* + version: link:packages/shared '@typescript/native-preview': specifier: 7.0.0-dev.20260319.1 version: 7.0.0-dev.20260319.1 '@voidzero-dev/vite-plus-core': specifier: ^0.1.12 version: 0.1.12(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + effect: + specifier: 4.0.0-beta.35 + version: 4.0.0-beta.35 turbo: specifier: ^2.8.17 version: 2.8.17 @@ -29,7 +38,7 @@ importers: version: 5.9.3 vite-plus: specifier: ^0.1.12 - version: 0.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + version: 0.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) apps/cli: dependencies: @@ -148,12 +157,18 @@ importers: '@types/react': specifier: ^19.2.14 version: 19.2.14 + '@types/serve-handler': + specifier: ^6.1.4 + version: 6.1.4 babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 expect-sdk: specifier: workspace:* version: link:../../packages/typescript-sdk + serve-handler: + specifier: ^6.1.7 + version: 6.1.7 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -167,6 +182,12 @@ importers: '@base-ui/react': specifier: ^1.3.0 version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@effect/atom-react': + specifier: 4.0.0-beta.35 + version: 4.0.0-beta.35(effect@4.0.0-beta.35)(react@19.2.4)(scheduler@0.27.0) + '@expect/shared': + specifier: workspace:* + version: link:../../packages/shared '@posthog/rrweb': specifier: ^0.0.50 version: 0.0.50 @@ -185,6 +206,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + effect: + specifier: 4.0.0-beta.35 + version: 4.0.0-beta.35 lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.4) @@ -326,6 +350,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@effect/vitest': + specifier: 4.0.0-beta.35 + version: 4.0.0-beta.35(effect@4.0.0-beta.35)(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))) '@types/node': specifier: ^22.15.0 version: 22.19.15 @@ -341,6 +368,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.35 version: 4.0.0-beta.35(effect@4.0.0-beta.35)(ioredis@5.10.0) + '@expect/shared': + specifier: workspace:* + version: link:../shared default-browser: specifier: ^5.5.0 version: 5.5.0 @@ -415,12 +445,21 @@ importers: effect: specifier: 4.0.0-beta.35 version: 4.0.0-beta.35 + figures: + specifier: ^6.1.0 + version: 6.1.0 oxc-resolver: specifier: ^11.19.1 version: 11.19.1 pathe: specifier: ^2.0.3 version: 2.0.3 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + pretty-ms: + specifier: ^9.3.0 + version: 9.3.0 simple-git: specifier: ^3.33.0 version: 3.33.0 @@ -792,6 +831,12 @@ packages: effect: ^4.0.0-beta.35 ioredis: ^5.7.0 + '@effect/vitest@4.0.0-beta.35': + resolution: {integrity: sha512-wdt6j7yNL8cYXSyi1QWch4UPpjB0C7QBzStmdMSmIzAZ3ZVAUcyQkdfg/hnUJt6NPZMdjVmbI7m93ml5Mqqnhw==} + peerDependencies: + effect: ^4.0.0-beta.35 + vitest: ^3.0.0 || ^4.0.0 + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -2843,6 +2888,9 @@ packages: '@types/request@2.48.13': resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + '@types/serve-handler@6.1.4': + resolution: {integrity: sha512-aXy58tNie0NkuSCY291xUxl0X+kGYy986l4kqW6Gi4kEXgr6Tx0fpSH7YwUSa5usPpG3s9DBeIR6hHcDtL2IvQ==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -3097,6 +3145,35 @@ packages: vue-router: optional: true + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + '@voidzero-dev/vite-plus-core@0.1.12': resolution: {integrity: sha512-j8YNe7A+8JcSoddztf5whvom/yJ7OKUO3Y5a3UoLIUmOL8YEKVv5nPANrxJ7eaFfHJoMnBEwzBpq1YVZ+H3uPA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3562,6 +3639,10 @@ packages: resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} engines: {node: '>=20'} + bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -3604,6 +3685,10 @@ packages: caniuse-lite@1.0.30001778: resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3735,6 +3820,10 @@ packages: config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -4041,6 +4130,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -4215,6 +4307,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -4254,6 +4349,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.3.1: resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} engines: {node: '>= 16'} @@ -5167,30 +5266,60 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.31.1: resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.31.1: resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.31.1: resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.31.1: resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.31.1: resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} @@ -5198,6 +5327,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} @@ -5205,6 +5341,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} @@ -5212,6 +5355,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} @@ -5219,22 +5369,45 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.31.1: resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.31.1: resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -5337,6 +5510,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -5345,6 +5522,10 @@ packages: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} @@ -5791,6 +5972,9 @@ packages: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -5806,6 +5990,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -5991,6 +6178,10 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -6225,6 +6416,9 @@ packages: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} + serve-handler@6.1.7: + resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -6283,6 +6477,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -6377,6 +6574,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -6616,6 +6816,10 @@ packages: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tldts-core@7.0.25: resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} @@ -6922,6 +7126,41 @@ packages: yaml: optional: true + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-haptics@0.0.6: resolution: {integrity: sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==} peerDependencies: @@ -6991,6 +7230,11 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + widest-line@6.0.0: resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} engines: {node: '>=20'} @@ -7574,6 +7818,11 @@ snapshots: - bufferutil - utf-8-validate + '@effect/vitest@4.0.0-beta.35(effect@4.0.0-beta.35)(vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)))': + dependencies: + effect: 4.0.0-beta.35 + vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)) + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -8226,6 +8475,14 @@ snapshots: optionalDependencies: '@types/node': 20.19.37 + '@inquirer/confirm@5.1.21(@types/node@22.19.15)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) + optionalDependencies: + '@types/node': 22.19.15 + optional: true + '@inquirer/core@10.3.2(@types/node@20.19.37)': dependencies: '@inquirer/ansi': 1.0.2 @@ -8239,6 +8496,20 @@ snapshots: optionalDependencies: '@types/node': 20.19.37 + '@inquirer/core@10.3.2(@types/node@22.19.15)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.15) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.15 + optional: true + '@inquirer/external-editor@1.0.3(@types/node@22.19.15)': dependencies: chardet: 2.1.1 @@ -8252,6 +8523,11 @@ snapshots: optionalDependencies: '@types/node': 20.19.37 + '@inquirer/type@3.0.10(@types/node@22.19.15)': + optionalDependencies: + '@types/node': 22.19.15 + optional: true + '@ioredis/commands@1.5.1': {} '@isaacs/cliui@9.0.0': {} @@ -9411,6 +9687,10 @@ snapshots: '@types/tough-cookie': 4.0.5 form-data: 2.5.5 + '@types/serve-handler@6.1.4': + dependencies: + '@types/node': 22.19.15 + '@types/statuses@2.0.6': {} '@types/tinycolor2@1.4.6': {} @@ -9618,6 +9898,48 @@ snapshots: next: 16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 + '@vitest/expect@4.1.2': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.2(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@22.19.15)(typescript@5.9.3) + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.2': {} + + '@vitest/utils@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@voidzero-dev/vite-plus-core@0.1.12(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)': dependencies: '@oxc-project/runtime': 0.115.0 @@ -9646,7 +9968,7 @@ snapshots: '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.12': optional: true - '@voidzero-dev/vite-plus-test@0.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)': + '@voidzero-dev/vite-plus-test@0.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 @@ -9660,7 +9982,7 @@ snapshots: tinybench: 2.9.0 tinyexec: 1.0.2 tinyglobby: 0.2.15 - vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) ws: 8.19.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -10021,6 +10343,8 @@ snapshots: byte-counter@0.1.0: {} + bytes@3.0.0: {} + bytes@3.1.2: {} cac@6.7.14: {} @@ -10064,6 +10388,8 @@ snapshots: caniuse-lite@1.0.30001778: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -10180,6 +10506,8 @@ snapshots: ini: 1.3.8 proto-list: 1.2.4 + content-disposition@0.5.2: {} + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -10516,6 +10844,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -10820,6 +11150,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -10872,6 +11206,8 @@ snapshots: expand-template@2.0.3: optional: true + expect-type@1.3.0: {} + express-rate-limit@8.3.1(express@5.2.1): dependencies: express: 5.2.1 @@ -11896,36 +12232,69 @@ snapshots: lightningcss-android-arm64@1.31.1: optional: true + lightningcss-android-arm64@1.32.0: + optional: true + lightningcss-darwin-arm64@1.31.1: optional: true + lightningcss-darwin-arm64@1.32.0: + optional: true + lightningcss-darwin-x64@1.31.1: optional: true + lightningcss-darwin-x64@1.32.0: + optional: true + lightningcss-freebsd-x64@1.31.1: optional: true + lightningcss-freebsd-x64@1.32.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + lightningcss-linux-arm64-gnu@1.31.1: optional: true + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + lightningcss-linux-arm64-musl@1.31.1: optional: true + lightningcss-linux-arm64-musl@1.32.0: + optional: true + lightningcss-linux-x64-gnu@1.31.1: optional: true + lightningcss-linux-x64-gnu@1.32.0: + optional: true + lightningcss-linux-x64-musl@1.31.1: optional: true + lightningcss-linux-x64-musl@1.32.0: + optional: true + lightningcss-win32-arm64-msvc@1.31.1: optional: true + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + lightningcss-win32-x64-msvc@1.31.1: optional: true + lightningcss-win32-x64-msvc@1.32.0: + optional: true + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 @@ -11942,6 +12311,23 @@ snapshots: lightningcss-win32-arm64-msvc: 1.31.1 lightningcss-win32-x64-msvc: 1.31.1 + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + optional: true + lines-and-columns@1.2.4: {} locate-path@5.0.0: @@ -12025,10 +12411,16 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.33.0: {} + mime-db@1.52.0: {} mime-db@1.54.0: {} + mime-types@2.1.18: + dependencies: + mime-db: 1.33.0 + mime-types@2.1.35: dependencies: mime-db: 1.52.0 @@ -12138,6 +12530,32 @@ snapshots: transitivePeerDependencies: - '@types/node' + msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@22.19.15) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.13.1 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + multipasta@0.2.7: {} mute-stream@2.0.0: {} @@ -12556,6 +12974,8 @@ snapshots: path-exists@5.0.0: {} + path-is-inside@1.0.2: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -12567,6 +12987,8 @@ snapshots: lru-cache: 11.2.7 minipass: 7.1.3 + path-to-regexp@3.3.0: {} + path-to-regexp@6.3.0: {} path-to-regexp@8.3.0: {} @@ -12775,6 +13197,8 @@ snapshots: quick-lru@5.1.1: {} + range-parser@1.2.0: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -13085,6 +13509,16 @@ snapshots: seroval@1.5.1: {} + serve-handler@6.1.7: + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + mime-types: 2.1.18 + minimatch: 3.1.5 + path-is-inside: 1.0.2 + path-to-regexp: 3.3.0 + range-parser: 1.2.0 + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -13231,6 +13665,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -13338,6 +13774,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} statuses@2.0.2: {} @@ -13617,6 +14055,8 @@ snapshots: tinypool@2.1.0: {} + tinyrainbow@3.1.0: {} + tldts-core@7.0.25: {} tldts@7.0.25: @@ -13869,11 +14309,11 @@ snapshots: vary@1.1.2: {} - vite-plus@0.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2): + vite-plus@0.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2): dependencies: '@oxc-project/types': 0.115.0 '@voidzero-dev/vite-plus-core': 0.1.12(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) - '@voidzero-dev/vite-plus-test': 0.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + '@voidzero-dev/vite-plus-test': 0.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) cac: 6.7.14 cross-spawn: 7.0.6 oxfmt: 0.40.0 @@ -13915,7 +14355,7 @@ snapshots: - vite - yaml - vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) @@ -13927,11 +14367,39 @@ snapshots: '@types/node': 22.19.15 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.31.1 + lightningcss: 1.32.0 terser: 5.46.1 tsx: 4.21.0 yaml: 2.8.2 + vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.19.15 + transitivePeerDependencies: + - msw + web-haptics@0.0.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): optionalDependencies: react: 19.2.4 @@ -14003,6 +14471,11 @@ snapshots: dependencies: isexe: 4.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@6.0.0: dependencies: string-width: 8.2.0 From 4ad41fa3d26a0ae5b781303a4d40ac790cea5b7d Mon Sep 17 00:00:00 2001 From: skoshx Date: Mon, 6 Apr 2026 01:17:40 +0300 Subject: [PATCH 2/3] feat: improved prompt testing --- .../screens/cookie-sync-confirm-screen.tsx | 33 ++-- apps/cli/src/data/execution-atom.ts | 29 ++-- apps/cli/src/utils/run-test.ts | 21 +-- apps/website/next-env.d.ts | 2 +- packages/agent/src/build-session-meta.ts | 2 +- packages/shared/src/models.ts | 1 + packages/shared/src/prompts.ts | 163 ++++-------------- packages/shared/tests/prompts.test.ts | 1 + packages/supervisor/src/executor.ts | 13 +- packages/supervisor/src/output-reporter.ts | 1 + 10 files changed, 69 insertions(+), 197 deletions(-) diff --git a/apps/cli/src/components/screens/cookie-sync-confirm-screen.tsx b/apps/cli/src/components/screens/cookie-sync-confirm-screen.tsx index 453daf6a0..cc628f860 100644 --- a/apps/cli/src/components/screens/cookie-sync-confirm-screen.tsx +++ b/apps/cli/src/components/screens/cookie-sync-confirm-screen.tsx @@ -29,7 +29,7 @@ export const CookieSyncConfirmScreen = ({ const COLORS = useColors(); const setScreen = useNavigationStore((state) => state.setScreen); const setCookieImportProfiles = useProjectPreferencesStore( - (state) => state.setCookieImportProfiles + (state) => state.setCookieImportProfiles, ); const { data, isLoading } = useInstalledBrowsers(); @@ -41,8 +41,7 @@ export const CookieSyncConfirmScreen = ({ const itemCount = items.length; useEffect(() => { - if (defaultsInitialized.current || !data || data.browsers.length === 0) - return; + if (defaultsInitialized.current || !data || data.browsers.length === 0) return; defaultsInitialized.current = true; if (Option.isSome(data.default)) { setSelectedIds(new Set([data.default.value.id])); @@ -62,15 +61,11 @@ export const CookieSyncConfirmScreen = ({ }; const confirm = () => { - const selectedProfiles = items.filter((browser) => - selectedIds.has(browser.id) - ); + const selectedProfiles = items.filter((browser) => selectedIds.has(browser.id)); setCookieImportProfiles(selectedProfiles); trackEvent("cookies:browser_selection", { selected_count: selectedProfiles.length, - browsers: selectedProfiles - .map((browser) => browser.displayName) - .join(", "), + browsers: selectedProfiles.map((browser) => browser.displayName).join(", "), }); if (selectedProfiles.length > 0) { trackEvent("cookies:browser_selection", { @@ -86,7 +81,7 @@ export const CookieSyncConfirmScreen = ({ instruction, savedFlow, cookieImportProfiles: selectedProfiles, - }) + }), ); } else { setScreen(Screen.Main()); @@ -135,24 +130,20 @@ export const CookieSyncConfirmScreen = ({ {" "} {figures.pointerSmall}{" "} - - {instruction ?? "Select browsers for cookie sync"} - + {instruction ?? "Select browsers for cookie sync"} {selectedCount > 0 && ( - {figures.tick} Your signed-in session will be synced from{" "} - {selectedCount} browser + {figures.tick} Your signed-in session will be synced from {selectedCount} browser {selectedCount === 1 ? "" : "s"} )} {selectedCount === 0 && ( - {figures.warning} No browsers selected — tests run without - authentication + {figures.warning} No browsers selected — tests run without authentication )} @@ -169,8 +160,7 @@ export const CookieSyncConfirmScreen = ({ const id = browser.id; const isHighlighted = index === highlightedIndex; const isSelected = selectedIds.has(id); - const isDefault = - Option.isSome(data!.default) && data!.default.value.id === id; + const isDefault = Option.isSome(data!.default) && data!.default.value.id === id; return ( @@ -180,10 +170,7 @@ export const CookieSyncConfirmScreen = ({ {isSelected ? figures.checkboxOn : figures.checkboxOff}{" "} - + {browser.displayName} {isDefault && (default)} diff --git a/apps/cli/src/data/execution-atom.ts b/apps/cli/src/data/execution-atom.ts index e79d4083b..03c3b6a1d 100644 --- a/apps/cli/src/data/execution-atom.ts +++ b/apps/cli/src/data/execution-atom.ts @@ -1,12 +1,6 @@ import { Effect, Option, Stream } from "effect"; import * as Atom from "effect/unstable/reactivity/Atom"; -import { - ExecutedTestPlan, - Executor, - Git, - Reporter, - type ExecuteOptions, -} from "@expect/supervisor"; +import { ExecutedTestPlan, Executor, Git, Reporter, type ExecuteOptions } from "@expect/supervisor"; import { Analytics } from "@expect/shared/observability"; import { LIVE_VIEWER_STATIC_URL } from "@expect/shared"; import type { AgentBackend } from "@expect/agent"; @@ -16,17 +10,14 @@ import { cliAtomRuntime } from "./runtime"; const REPLAY_REPORT_PREFIX = "rrweb report:"; const PLAYWRIGHT_VIDEO_PREFIX = "Playwright video:"; -const artifactViewerUrl = (planId: string) => - `${LIVE_VIEWER_STATIC_URL}/replay/?testId=${planId}`; +const artifactViewerUrl = (planId: string) => `${LIVE_VIEWER_STATIC_URL}/replay/?testId=${planId}`; interface ExecuteInput { readonly options: ExecuteOptions; readonly agentBackend: AgentBackend; readonly onUpdate: (executed: ExecutedTestPlan) => void; readonly onReplayUrl?: (url: string) => void; - readonly onConfigOptions?: ( - configOptions: readonly AcpConfigOption[] - ) => void; + readonly onConfigOptions?: (configOptions: readonly AcpConfigOption[]) => void; readonly onLiveViewUrl?: (url: string) => void; } @@ -57,7 +48,7 @@ export const executeAtomFn = cliAtomRuntime.fn( Stream.tap((executed) => Effect.sync(() => { input.onUpdate(executed); - }) + }), ), Stream.runLast, Effect.map((option) => @@ -82,17 +73,17 @@ export const executeAtomFn = cliAtomRuntime.fn( }) ) .finalizeTextBlock() - .synthesizeRunFinished() - ) + .synthesizeRunFinished(), + ), ); const report = yield* reporter.report(finalExecuted); const passedCount = report.steps.filter( - (step) => report.stepStatuses.get(step.id)?.status === "passed" + (step) => report.stepStatuses.get(step.id)?.status === "passed", ).length; const failedCount = report.steps.filter( - (step) => report.stepStatuses.get(step.id)?.status === "failed" + (step) => report.stepStatuses.get(step.id)?.status === "failed", ).length; yield* analytics.capture("run:completed", { @@ -115,6 +106,6 @@ export const executeAtomFn = cliAtomRuntime.fn( } satisfies ExecutionResult; }, Effect.annotateLogs({ fn: "executeAtomFn" }), - Effect.withSpan("expect.session") - ) + Effect.withSpan("expect.session"), + ), ); diff --git a/apps/cli/src/utils/run-test.ts b/apps/cli/src/utils/run-test.ts index a9b6c56a3..93c6062da 100644 --- a/apps/cli/src/utils/run-test.ts +++ b/apps/cli/src/utils/run-test.ts @@ -8,7 +8,7 @@ import { layerCli } from "../layers"; import { playSound } from "./play-sound"; class ExecutionTimeoutError extends Schema.ErrorClass( - "ExecutionTimeoutError" + "ExecutionTimeoutError", )({ _tag: Schema.tag("ExecutionTimeoutError"), timeoutMs: Schema.Number, @@ -42,9 +42,7 @@ export const runHeadless = (options: HeadlessRunOptions) => const cookieImportProfiles = options.noCookies || options.browserProfileIds.length === 0 ? [] - : yield* Effect.forEach(options.browserProfileIds, (id) => - browsers.findById(id) - ); + : yield* Effect.forEach(options.browserProfileIds, (id) => browsers.findById(id)); const sessionStartedAt = Date.now(); yield* analytics.capture("session:started", { @@ -70,16 +68,13 @@ export const runHeadless = (options: HeadlessRunOptions) => ? executeStream.pipe( Effect.timeoutOrElse({ duration: `${timeoutMs} millis`, - onTimeout: () => - Effect.fail(new ExecutionTimeoutError({ timeoutMs })), - }) + onTimeout: () => Effect.fail(new ExecutionTimeoutError({ timeoutMs })), + }), ) : executeStream; const finalExecuted = yield* executeWithTimeout.pipe( - Effect.flatMap((executedTestPlanOption) => - executedTestPlanOption.asEffect() - ) + Effect.flatMap((executedTestPlanOption) => executedTestPlanOption.asEffect()), ); const report = yield* reporter.report(finalExecuted); @@ -111,11 +106,11 @@ export const runHeadless = (options: HeadlessRunOptions) => timeoutMs: Option.getOrUndefined(options.timeoutMs), replayHost: options.replayHost, testId: options.testId, - }) + }), ), Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.die(cause) + Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.die(cause), ), Effect.tapCause((cause) => Effect.logFatal(cause)), - Effect.runPromise + Effect.runPromise, ); diff --git a/apps/website/next-env.d.ts b/apps/website/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/website/next-env.d.ts +++ b/apps/website/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/agent/src/build-session-meta.ts b/packages/agent/src/build-session-meta.ts index 73f968261..dfc65d71f 100644 --- a/packages/agent/src/build-session-meta.ts +++ b/packages/agent/src/build-session-meta.ts @@ -18,7 +18,7 @@ export const buildSessionMeta = ({ provider, systemPrompt, metadata }: BuildSess ? { claudeCode: { options: { - tools: { type: "preset", preset: "claude_code" }, + tools: [], settings: { allowedMcpServers: [{ serverName: BROWSER_MCP_SERVER_NAME }], }, diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 68ca5da97..2d823fd64 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -1155,6 +1155,7 @@ export class ExecutedTestPlan extends TestPlan.extend( .split("\n") .map(parseMarker) .filter(Predicate.isNotUndefined); + console.log([lastEvent._tag === "AgentThinking" ? `🧠` : `🎙️`, lastEvent.text].join(" ")); if (foundMarkers.length === 0) return this; let result: ExecutedTestPlan = new ExecutedTestPlan({ ...this, diff --git a/packages/shared/src/prompts.ts b/packages/shared/src/prompts.ts index 1278009b6..b1d70697a 100644 --- a/packages/shared/src/prompts.ts +++ b/packages/shared/src/prompts.ts @@ -113,82 +113,36 @@ const formatTestCoverageSection = (testCoverage: TestCoverageReport | undefined) return lines; }; -export const buildExecutionSystemPrompt = (browserMcpServerName?: string): string => { - const mcpName = browserMcpServerName ?? DEFAULT_BROWSER_MCP_SERVER_NAME; +export const buildExecutionPrompt = (options: ExecutionPromptOptions): string => { + const mcpName = options.browserMcpServerName ?? DEFAULT_BROWSER_MCP_SERVER_NAME; + const changedFiles = options.changedFiles.slice(0, EXECUTION_CONTEXT_FILE_LIMIT); + const recentCommits = options.recentCommits.slice(0, EXECUTION_RECENT_COMMIT_LIMIT); + const rawDiff = options.diffPreview || ""; + const diffPreview = + rawDiff.length > DIFF_PREVIEW_CHAR_LIMIT + ? rawDiff.slice(0, DIFF_PREVIEW_CHAR_LIMIT) + "\n... (truncated)" + : rawDiff; + + const devServerLines = + options.devServerHints && options.devServerHints.length > 0 + ? [ + "Dev servers (not running — start before testing):", + ...options.devServerHints.map( + (hint) => ` cd ${hint.projectPath} && ${hint.devCommand} → ${hint.url}`, + ), + ] + : []; return [ - "You are a QA engineer testing code changes in a real browser. Your job is to find bugs the developer missed, not confirm the happy path works.", - "", - "You have two documented failure patterns. First, happy-path seduction: the page loads, the primary flow works, and you emit RUN_COMPLETED without testing edge cases, viewports, or adjacent flows — the easy 80% passes and the bugs hide in the untested 20%. Second, soft failures: a check fails but the page 'mostly works,' so you emit STEP_DONE instead of ASSERTION_FAILED, hiding the bug from the developer.", - "", - "", - "The diff preview, changed files list, and recent commits are already provided in the prompt. Do NOT call tools to re-read or re-diff those files — all the context you need to plan is already here.", - "- Scan the provided changed files list and diff preview to identify what behavior changed and which user flows to test.", - "- Group related files into concrete flows. A flow is an end-to-end path with a clear entry point, user action, and observable outcome.", - "- Treat the diff as the source of truth. The developer request is a starting point, not the full scope.", - "- Files without existing automated tests are higher risk. Give them deeper browser coverage when they touch runtime behavior.", - "", - "", - "", - "Minimum bar: every changed route, page, form, mutation, API interaction, auth gate, shared component, shared hook, or shared utility that affects runtime behavior must be covered by at least one tested flow or one code-level check.", - "- When shared code changes, test multiple consumers instead of one happy path.", - "- If a diff changes validation, branching logic, permissions, loading, empty, or error handling, include the matching negative or edge-case path.", - "- If a diff changes persistence or mutations, verify the before/after state and one durability check (refresh, revisit, or back-navigation).", - "- If multiple files implement one feature, test the full user journey end-to-end instead of isolated clicks.", - "", + "You are a QA engineer testing code changes in a real browser. Your job is to confirm that flows given to you by the user works as described.", "", - "", - "- First master the primary flow the developer asked for. Verify it thoroughly before moving on.", - "- Once the primary flow passes, test additional related flows suggested by the changed files, diff semantics, and route context. The scope strategy below specifies how many.", - "- For each flow, test both the happy path AND at least one edge case or negative path (e.g. empty input, missing data, back-navigation, double-click, refresh mid-flow).", - "- Use the same browser session throughout unless the app forces you into a different path.", - "- Execution style is assertion-first: navigate, act, then validate before moving on.", - "- Create your own step structure while executing. Use stable sequential IDs like step-01, step-02, step-03.", - "- For each step, verify the action produced the expected state change. Check at least two independent signals (e.g. URL changed AND new content appeared, or item added AND count updated).", - "- Verify absence when relevant: after a delete, the item is gone; after dismissing a modal, it no longer appears in the tree.", - "- Use playwright to return structured evidence: current URL, page title, and visibility of the target element.", - "- If the changed files suggest specific behavior (e.g. a validation rule, a redirect, a computed value), test that specific behavior rather than just the surrounding UI.", - "", + "", + "Your default verdict for every step is ASSERTION_FAILED. You must collect enough concrete evidence to overturn that default. If the evidence is ambiguous or requires interpretation, the default stands — fail the step.", "", - "", - "Every page you test MUST have real data. If a page shows an empty state, zero records, or placeholder content, seed it before testing. An empty-state screenshot is not a test — it is a skip.", + "Never speculate about developer intent to justify a pass. Phrases like 'could be by design', 'might be intentional', 'probably expected behavior' are banned. You are testing observable behavior against the test expectation. If observed behavior does not match the expectation, fail. You do not know what is intentional — you only know what was requested and what you saw.", "", - "1. Navigate to the target page. Snapshot. If data exists and is sufficient, proceed to testing.", - "2. If empty or insufficient: find the creation flow ('Add', 'New', 'Create', 'Import') and use it. If the app exposes an API you can call via playwright's page.evaluate(fetch(...)), prefer that for speed.", - "3. Create the full dependency chain top-down. A paystub requires company → employee → payroll run → paystub. Do not skip intermediate objects.", - "4. Create MINIMUM 3 records. One record hides pagination, sorting, bulk-action, and empty-vs-populated bugs.", - "5. After seeding, return to the target page and snapshot. If the data does not appear, emit ASSERTION_FAILED — the creation flow is broken.", - "6. Prefix every seed step with [Setup]: STEP_START|step-01|[Setup] Create employee with adversarial name", - "", - "Adversarial seed values — each record MUST use a different category. Rotate across your 3+ records:", - "- Unicode stress: German umlauts + hyphen ('Günther Müller-Lüdenscheid'), Arabic RTL ('مريم الفارسي'), CJK ('田中太郎'), Zalgo combining chars ('T̸̢̧ë̵̡s̶̨̛t̷̢̛')", - "- Boundary values: 0, -1, 999999999.99, 0.001 for numbers. Empty string and 5000+ chars for text. '' for XSS.", - "- Edge dates: '1970-01-01' (epoch), a date in the current month, and an obviously invalid date if the field allows free input.", - "- Truncation: 100+ character email, 200+ character name, max-length strings. These catch overflow and ellipsis bugs.", - "- Dropdowns: always select the LAST option at least once — it is the least tested.", - "", - "Bad: navigate to /employees, see 'No employees yet', screenshot, emit STEP_DONE|step-01|employee list page renders correctly.", - "Good: navigate to /employees, see 'No employees yet', find 'Add Employee' button, create 3 employees with adversarial names, return to /employees, verify all 3 appear in the table, THEN test the actual feature.", - "", - "Rationalizations you will reach for — recognize them and do the opposite:", - "- 'The empty state renders correctly' — you were not asked to test the empty state. Seed data.", - "- 'One record is enough to verify the feature' — one record hides half the bugs. Three is the minimum.", - "- 'Creating data will take too long' — testing against empty data wastes the entire run. Seed first.", - "- 'I don't have the right permissions to create data' — try the creation flow first. Only emit STEP_SKIPPED with category=missing-test-data if it actually fails.", - "- 'The developer probably has data in their environment' — you do not know that. Check and seed.", - "", - "", - "", - "After completing the primary functional tests, run a dedicated UI quality pass when the diff touches files that affect visual output (components, styles, layouts, templates, routes). Skip this section when the diff only changes backend logic, build config, or tests. When applicable, these checks are mandatory. Emit each as its own step.", - "", - "1. Design system conformance: inspect for tailwind.config, CSS custom properties, component libraries, token files. Verify changed elements use the system's tokens. Flag hardcoded hex/rgb colors, pixel spacing, or font-family declarations that bypass the design system.", - "2. Responsive design: test at these viewports using page.setViewportSize: 375×812 (iPhone SE), 390×844 (iPhone 14), 768×1024 (iPad Mini), 810×1080 (iPad Air), 1024×768 (iPad landscape), 1280×800 (laptop), 1440×900 (desktop). Verify no horizontal overflow, no overlapping elements, text readable, interactive elements accessible. Do not skip tablets.", - "3. Touch interaction: if the diff modifies interactive elements, test them with touch in addition to click at a mobile viewport. Verify flows that work via click also complete via tap.", - "4. Cross-browser (Safari/WebKit): launch a WebKit browser context and re-run the primary flow. Check for flexbox gap, backdrop-filter, position:sticky in overflow, date/time inputs, scrollbar styling, -webkit-line-clamp. If WebKit is unavailable, emit STEP_SKIPPED.", - "5. Dark mode: detect support (dark: Tailwind classes, theme toggle, prefers-color-scheme, data-theme attribute). If supported, switch and re-verify. Check for invisible text, disappearing borders, icons assuming light background, hardcoded white backgrounds. If no dark mode detected, emit STEP_SKIPPED.", - "6. Layout stability (CLS): after networkidle, measure cumulative layout shift via PerformanceObserver. CLS above 0.1 is a failure, 0.05-0.1 is a warning. If high, screenshot immediately and 3 seconds later.", - "7. Font loading: after networkidle, check document.fonts API. Every font must have status 'loaded'. Verify @font-face or preload tags exist. Flag system-font-only text unless the design system specifies a system stack.", - "", + "Before emitting STEP_DONE, you must write a counter-evidence review: list every suspicious observation from the step (unexpected text, layout shifts, console warnings, slow loads, missing elements, partial renders). Only after documenting all counter-evidence AND determining that none of it contradicts the pass verdict may you emit STEP_DONE. If you cannot write down at least one concrete piece of positive evidence, the step is not STEP_DONE.", + "", "", ``, "1. open: launch a browser and navigate to a URL. Pass browser='webkit' or browser='firefox' to launch a non-Chromium engine (e.g. for cross-browser testing). Close the current session first before switching engines.", @@ -218,40 +172,6 @@ export const buildExecutionSystemPrompt = (browserMcpServerName?: string): strin "Scroll-aware snapshots: snapshots only show elements visible in scroll containers. Hidden items appear as '- note \"N items hidden above/below\"'. To reveal hidden content, scroll using playwright: await page.evaluate(() => document.querySelector('[aria-label=\"List\"]').scrollTop += 500). Then take a new snapshot. Use fullPage=true in screenshot to include all elements.", "", "", - "", - "If the diff only touches internal logic with no user-visible surface (utilities, algorithms, backend, CLI, build scripts), use your shell tool to run the project's test suite instead of a browser session. Same step protocol applies.", - "If changes are mixed, browser-test the UI parts and code-test the rest.", - "", - "", - "", - "You will feel the urge to skip checks or soften results. These are the exact excuses you reach for — recognize them and do the opposite:", - '- "The page loaded successfully" — loading is not verification. Check the specific behavior the diff changed.', - '- "This viewport looks fine" — did you check all required viewports? Skipping one is not testing it.', - '- "The test coverage section shows this file is already tested" — existing tests are written by the developer. Your job is to catch what they missed.', - '- "This styling change is too small to need all 7 checks" — if the diff touches visual files, every applicable check runs regardless of change size.', - '- "The primary flow passed, so the feature works" — the primary flow is the easy 80%. Test the adjacent flows.', - '- "I already checked this visually" — visual checks without structured evidence are not verification. Use playwright to return concrete data.', - "If you catch yourself narrating what you would test instead of running a tool call, stop. Run the tool call.", - "", - "", - "", - "- After navigation or major UI changes, wait for the page to settle (await page.waitForLoadState('networkidle')).", - "- Confirm you reached the expected page or route before continuing.", - "- Use event-driven waits (waitForSelector, waitForURL, waitForFunction) instead of timed delays. Take a new snapshot after each wait resolves.", - "- When blocked: take a new snapshot for fresh refs, scroll the target into view or retry once.", - "- If still blocked after one retry, classify the blocker with one allowed failure category and emit ASSERTION_FAILED.", - "- Do not repeat the same failing action without new evidence (fresh snapshot, different ref, changed page state).", - "- If four attempts fail or progress stalls, stop and report what you observed, what blocked progress, and the most likely next step.", - "- If you encounter missing test data (empty lists, no records, 'no results' states), treat it as a resolvable blocker — follow the procedure before giving up.", - "- If you encounter a hard blocker (login, passkey, captcha, permissions), stop and report it instead of improvising.", - "", - "", - "", - "- Short timed waits (under 2 seconds) are acceptable for animations, debounced inputs, and CSS transitions where no DOM event signals completion. Timed waits over 10 seconds are almost always wrong — use an event-driven wait instead.", - "- When starting a dev server, launch it in background and use page.goto() with retry — do not poll with sleep loops.", - "- Batch independent tool calls in a single message. If you need a snapshot AND console logs AND network requests, request all three at once.", - "", - "", "", "Emit these exact status markers on their own lines during execution. The test run fails without them.", "", @@ -267,7 +187,11 @@ export const buildExecutionSystemPrompt = (browserMcpServerName?: string): strin "Every test run must have at least one STEP_START/STEP_DONE pair and must end with RUN_COMPLETED. Emit each marker as a standalone line with no surrounding formatting or markdown.", "Use STEP_SKIPPED when a step cannot be executed due to missing prerequisites (e.g. test credentials not available, auth-blocked). Never use STEP_DONE for steps that were not actually tested.", "", - "Before emitting STEP_DONE, verify you have at least one concrete piece of evidence (URL, text content, snapshot ref, console output, measurement result) proving the step passed. A step without evidence is not a STEP_DONE — it is a skip.", + "Before emitting STEP_DONE you must:", + " 1. State the positive evidence (URL, text content, snapshot ref, console output, measurement) that proves the step passed.", + " 2. List all counter-evidence: every suspicious observation from the step (unexpected text, layout shifts, console warnings, slow loads, missing elements, partial renders, wrong values).", + " 3. Explain why the counter-evidence does not contradict the pass verdict. If you cannot, emit ASSERTION_FAILED instead.", + "A step without this review is not a STEP_DONE — it is a skip. You literally cannot emit STEP_DONE without first writing down everything suspicious you saw.", "Report outcomes faithfully. If a check fails, emit ASSERTION_FAILED with evidence. Never emit STEP_DONE for a step that showed failures, and never skip a mandatory check without emitting STEP_SKIPPED. The outer agent may re-execute your steps — if a STEP_DONE has no supporting evidence, the run is rejected.", "", "", @@ -294,29 +218,7 @@ export const buildExecutionSystemPrompt = (browserMcpServerName?: string): strin "5. Review the changed files list and confirm every file is accounted for by a tested flow, a code-level check, or an explicit blocker with evidence.", "Do not emit RUN_COMPLETED until all steps above are done.", "", - ].join("\n"); -}; - -export const buildExecutionPrompt = (options: ExecutionPromptOptions): string => { - const changedFiles = options.changedFiles.slice(0, EXECUTION_CONTEXT_FILE_LIMIT); - const recentCommits = options.recentCommits.slice(0, EXECUTION_RECENT_COMMIT_LIMIT); - const rawDiff = options.diffPreview || ""; - const diffPreview = - rawDiff.length > DIFF_PREVIEW_CHAR_LIMIT - ? rawDiff.slice(0, DIFF_PREVIEW_CHAR_LIMIT) + "\n... (truncated)" - : rawDiff; - - const devServerLines = - options.devServerHints && options.devServerHints.length > 0 - ? [ - "Dev servers (not running — start before testing):", - ...options.devServerHints.map( - (hint) => ` cd ${hint.projectPath} && ${hint.devCommand} → ${hint.url}`, - ), - ] - : []; - - return [ + "", "", ...(options.baseUrl ? [`Base URL: ${options.baseUrl}`] : []), ...devServerLines, @@ -353,9 +255,6 @@ export const buildExecutionPrompt = (options: ExecutionPromptOptions): string => options.userInstruction, "", "", - "", - ...getScopeStrategy(options.scope), - "", ].join("\n"); }; diff --git a/packages/shared/tests/prompts.test.ts b/packages/shared/tests/prompts.test.ts index cb664fde6..84ee5d5eb 100644 --- a/packages/shared/tests/prompts.test.ts +++ b/packages/shared/tests/prompts.test.ts @@ -192,6 +192,7 @@ describe("buildExecutionPrompt", () => { expect(prompt).toContain(""); expect(prompt).toContain(""); expect(prompt).toContain(""); + expect(prompt).toContain(""); expect(prompt).toContain(""); expect(prompt).toContain(""); diff --git a/packages/supervisor/src/executor.ts b/packages/supervisor/src/executor.ts index 32519ed5f..8629fcf4e 100644 --- a/packages/supervisor/src/executor.ts +++ b/packages/supervisor/src/executor.ts @@ -38,11 +38,7 @@ import { type TestCoverageReport, TestPlan, } from "@expect/shared/models"; -import { - buildExecutionPrompt, - buildExecutionSystemPrompt, - type DevServerHint, -} from "@expect/shared/prompts"; +import { buildExecutionPrompt, type DevServerHint } from "@expect/shared/prompts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Git } from "./git/git"; import { EXPECT_BASE_URL_ENV_NAME } from "@expect/browser/mcp"; @@ -157,8 +153,6 @@ export class Executor extends ServiceMap.Service()("@supervisor/Execut const context = yield* gatherContext(options.changesFor); - const systemPrompt = buildExecutionSystemPrompt(); - const prompt = buildExecutionPrompt({ userInstruction: options.instruction, scope: options.changesFor._tag, @@ -176,6 +170,9 @@ export class Executor extends ServiceMap.Service()("@supervisor/Execut devServerHints: options.devServerHints, }); + console.log("USER PROMPT: "); + console.log(prompt); + const syntheticPlan = new TestPlan({ id: planId, changesFor: options.changesFor, @@ -229,7 +226,7 @@ export class Executor extends ServiceMap.Service()("@supervisor/Execut cwd: process.cwd(), sessionId: Option.none(), prompt, - systemPrompt: Option.some(systemPrompt), + systemPrompt: Option.none(), mcpEnv, modelPreference: options.modelPreference, }); diff --git a/packages/supervisor/src/output-reporter.ts b/packages/supervisor/src/output-reporter.ts index 519c469ae..7402f792f 100644 --- a/packages/supervisor/src/output-reporter.ts +++ b/packages/supervisor/src/output-reporter.ts @@ -305,4 +305,5 @@ const printSummary = (write: (text: string) => Effect.Effect, report: Test ` ${pc.bold("Tests")} ${parts.join(pc.dim(" | "))} ${pc.dim(`(${report.steps.length})`)}`, ); yield* write(` ${pc.bold("Time")} ${formatElapsed(report.totalDurationMs)}`); + yield* write("\n" + report.toPlainText); }); From 3de8aa1352fa1ebc833d59ee2fccfc9445517332 Mon Sep 17 00:00:00 2001 From: Nisarg Patel Date: Sun, 5 Apr 2026 18:10:47 -0700 Subject: [PATCH 3/3] chore: update subproject commit and remove debug log from viewer command --- .repos/effect | 2 +- apps/cli/src/commands/viewer.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.repos/effect b/.repos/effect index 54769ed9a..66a0494ed 160000 --- a/.repos/effect +++ b/.repos/effect @@ -1 +1 @@ -Subproject commit 54769ed9aa8ee513bbdc4a15d51e2e4042e67394 +Subproject commit 66a0494ed75cd12f2721dcbb1d8a072e3d9e14b6 diff --git a/apps/cli/src/commands/viewer.ts b/apps/cli/src/commands/viewer.ts index 60da6b58a..fc3e0e814 100644 --- a/apps/cli/src/commands/viewer.ts +++ b/apps/cli/src/commands/viewer.ts @@ -10,7 +10,6 @@ interface ViewerOptions { } export const runViewer = (options: ViewerOptions = {}) => { - console.log("[viewer] options:", options); const layer = layerCli({ verbose: options.verbose ?? false, agent: options.agent ?? "claude",