diff --git a/.github/actions/setup-git-committer/action.yml b/.github/actions/setup-git-committer/action.yml index e5a5720652..87d2f5d0d4 100644 --- a/.github/actions/setup-git-committer/action.yml +++ b/.github/actions/setup-git-committer/action.yml @@ -23,6 +23,7 @@ runs: with: app-id: ${{ inputs.opencode-app-id }} private-key: ${{ inputs.opencode-app-secret }} + owner: ${{ github.repository_owner }} - name: Configure git user run: | diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b4369fa1a4..d8a5c8a902 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,7 @@ ### What does this PR do? +Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the pr. + +**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** + ### How did you verify your code works? diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index b9aa498962..a793bfb037 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -1,21 +1,15 @@ name: beta on: - push: - branches: [dev] - pull_request: - types: [opened, synchronize, labeled, unlabeled] + workflow_dispatch: + schedule: + - cron: "0 * * * *" jobs: sync: - if: | - github.event_name == 'push' || - (github.event_name == 'pull_request' && - contains(github.event.pull_request.labels.*.name, 'contributor')) runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write - pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 3bb8f364d6..706ab2989e 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -4,8 +4,6 @@ on: push: branches: - dev - pull_request: - workflow_dispatch: jobs: generate: diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 061b4ada8d..bb4db70882 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -21,11 +21,68 @@ on: - ".github/workflows/nix-hashes.yml" jobs: - nix-hashes: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + # Native runners required: bun install cross-compilation flags (--os/--cpu) + # do not produce byte-identical node_modules as native installs. + compute-hash: + strategy: + fail-fast: false + matrix: + include: + - system: x86_64-linux + runner: blacksmith-4vcpu-ubuntu-2404 + - system: aarch64-linux + runner: blacksmith-4vcpu-ubuntu-2404-arm + - system: x86_64-darwin + runner: macos-15-intel + - system: aarch64-darwin + runner: macos-latest + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Nix + uses: nixbuild/nix-quick-install-action@v34 + + - name: Compute node_modules hash + id: hash + env: + SYSTEM: ${{ matrix.system }} + run: | + set -euo pipefail + + BUILD_LOG=$(mktemp) + trap 'rm -f "$BUILD_LOG"' EXIT + + # Build with fakeHash to trigger hash mismatch and reveal correct hash + nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true + + HASH="$(grep -E 'got:\s+sha256-' "$BUILD_LOG" | sed -E 's/.*got:\s+(sha256-[A-Za-z0-9+/=]+).*/\1/' | head -n1 || true)" + if [ -z "$HASH" ]; then + HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | sed -E 's/.*got:\s+(sha256-[A-Za-z0-9+/=]+).*/\1/' | head -n1 || true)" + fi + + if [ -z "$HASH" ]; then + echo "::error::Failed to compute hash for ${SYSTEM}" + cat "$BUILD_LOG" + exit 1 + fi + + echo "$HASH" > hash.txt + echo "Computed hash for ${SYSTEM}: $HASH" + + - name: Upload hash + uses: actions/upload-artifact@v4 + with: + name: hash-${{ matrix.system }} + path: hash.txt + retention-days: 1 + + update-hashes: + needs: compute-hash + if: github.event_name != 'pull_request' runs-on: blacksmith-4vcpu-ubuntu-2404 - env: - TITLE: node_modules hashes steps: - name: Checkout repository @@ -33,108 +90,64 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - ref: ${{ github.head_ref || github.ref_name }} - repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.ref_name }} - name: Setup git committer - id: committer uses: ./.github/actions/setup-git-committer with: opencode-app-id: ${{ vars.OPENCODE_APP_ID }} opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 - - name: Pull latest changes - env: - TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} run: | - BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - git pull --rebase --autostash origin "$BRANCH" + git pull --rebase --autostash origin "$GITHUB_REF_NAME" - - name: Compute all node_modules hashes + - name: Download hash artifacts + uses: actions/download-artifact@v4 + with: + path: hashes + pattern: hash-* + + - name: Update hashes.json run: | set -euo pipefail HASH_FILE="nix/hashes.json" - SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin" - if [ ! -f "$HASH_FILE" ]; then - mkdir -p "$(dirname "$HASH_FILE")" - echo '{"nodeModules":{}}' > "$HASH_FILE" - fi + [ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE" - for SYSTEM in $SYSTEMS; do - echo "Computing hash for ${SYSTEM}..." - BUILD_LOG=$(mktemp) - trap 'rm -f "$BUILD_LOG"' EXIT - - # The updater derivations use fakeHash, so they will fail and reveal the correct hash - UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules" - - nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true - - CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)" - - if [ -z "$CORRECT_HASH" ]; then - CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)" + for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do + FILE="hashes/hash-${SYSTEM}/hash.txt" + if [ -f "$FILE" ]; then + HASH="$(tr -d '[:space:]' < "$FILE")" + echo "${SYSTEM}: ${HASH}" + jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json + mv tmp.json "$HASH_FILE" + else + echo "::warning::Missing hash for ${SYSTEM}" fi - - if [ -z "$CORRECT_HASH" ]; then - echo "Failed to determine correct node_modules hash for ${SYSTEM}." - cat "$BUILD_LOG" - exit 1 - fi - - echo " ${SYSTEM}: ${CORRECT_HASH}" - jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \ - '.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp" - mv "${HASH_FILE}.tmp" "$HASH_FILE" done - echo "All hashes computed:" cat "$HASH_FILE" - - name: Commit ${{ env.TITLE }} changes - env: - TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + - name: Commit changes run: | set -euo pipefail HASH_FILE="nix/hashes.json" - echo "Checking for changes..." - summarize() { - local status="$1" - { - echo "### Nix $TITLE" - echo "" - echo "- ref: ${GITHUB_REF_NAME}" - echo "- status: ${status}" - } >> "$GITHUB_STEP_SUMMARY" - if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then - echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY" - fi - echo "" >> "$GITHUB_STEP_SUMMARY" - } - - FILES=("$HASH_FILE") - STATUS="$(git status --short -- "${FILES[@]}" || true)" - if [ -z "$STATUS" ]; then - echo "No changes detected." - summarize "no changes" + if [ -z "$(git status --short -- "$HASH_FILE")" ]; then + echo "No changes to commit" + echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY" + echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY" exit 0 fi - echo "Changes detected:" - echo "$STATUS" - git add "${FILES[@]}" + git add "$HASH_FILE" git commit -m "chore: update nix node_modules hashes" - BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - git pull --rebase --autostash origin "$BRANCH" - git push origin HEAD:"$BRANCH" - echo "Changes pushed successfully" + git pull --rebase --autostash origin "$GITHUB_REF_NAME" + git push origin HEAD:"$GITHUB_REF_NAME" - summarize "committed $(git rev-parse --short HEAD)" + echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY" + echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3974d23ffc..a1b492258b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,7 +103,7 @@ jobs: target: x86_64-pc-windows-msvc - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu - - host: blacksmith-4vcpu-ubuntu-2404-arm + - host: blacksmith-8vcpu-ubuntu-2404-arm target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f6cbb16edc..2a36c07e14 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,9 @@ name: test on: + push: + branches: + - dev pull_request: workflow_dispatch: jobs: diff --git a/bun.lock b/bun.lock index b9145be812..2934011f4b 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -213,7 +213,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -242,7 +242,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -258,7 +258,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.45", + "version": "1.1.48", "bin": { "opencode": "./bin/opencode", }, @@ -266,25 +266,25 @@ "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.13.0", - "@ai-sdk/amazon-bedrock": "3.0.73", - "@ai-sdk/anthropic": "2.0.57", + "@ai-sdk/amazon-bedrock": "3.0.74", + "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/azure": "2.0.91", - "@ai-sdk/cerebras": "1.0.34", + "@ai-sdk/cerebras": "1.0.36", "@ai-sdk/cohere": "2.0.22", - "@ai-sdk/deepinfra": "1.0.31", - "@ai-sdk/gateway": "2.0.25", + "@ai-sdk/deepinfra": "1.0.33", + "@ai-sdk/gateway": "2.0.30", "@ai-sdk/google": "2.0.52", - "@ai-sdk/google-vertex": "3.0.97", + "@ai-sdk/google-vertex": "3.0.98", "@ai-sdk/groq": "2.0.34", "@ai-sdk/mistral": "2.0.27", "@ai-sdk/openai": "2.0.89", - "@ai-sdk/openai-compatible": "1.0.30", + "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/perplexity": "2.0.23", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", - "@ai-sdk/togetherai": "1.0.31", - "@ai-sdk/vercel": "1.0.31", - "@ai-sdk/xai": "2.0.51", + "@ai-sdk/togetherai": "1.0.34", + "@ai-sdk/vercel": "1.0.33", + "@ai-sdk/xai": "2.0.56", "@clack/prompts": "1.0.0-alpha.1", "@gitlab/gitlab-ai-provider": "3.3.1", "@hono/standard-validator": "0.1.5", @@ -297,7 +297,7 @@ "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", - "@openrouter/ai-sdk-provider": "1.5.2", + "@openrouter/ai-sdk-provider": "1.5.4", "@opentui/core": "0.1.75", "@opentui/solid": "0.1.75", "@parcel/watcher": "2.5.1", @@ -365,7 +365,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -385,7 +385,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.45", + "version": "1.1.48", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -396,7 +396,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -409,7 +409,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -451,7 +451,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "zod": "catalog:", }, @@ -462,7 +462,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.45", + "version": "1.1.48", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -524,7 +524,7 @@ "@types/node": "22.13.9", "@types/semver": "7.7.1", "@typescript/native-preview": "7.0.0-dev.20251207.1", - "ai": "5.0.119", + "ai": "5.0.124", "diff": "8.0.2", "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.12-a5629fb", @@ -564,23 +564,23 @@ "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="], - "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.73", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EAAGJ/dfbAZaqIhK3w52hq6cftSLZwXdC6uHKh8Cls1T0N4MxS6ykDf54UyFO3bZWkQxR+Mdw1B3qireGOxtJQ=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="], "@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="], - "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XOK0dJsAGoPYi/lfR4KFBi8xhvJ46oCpAxUD6FmJAuJ4eh0qlj5zDt+myvzM8gvN7S6K7zHD+mdWlOPKGQT8Vg=="], + "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="], "@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="], - "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-87qFcYNvDF/89hB//MQjYTb3tlsAfmgeZrZ34RESeBTZpSgs0EzYOMqPMwFTHUNp4wteoifikDJbaS/9Da8cfw=="], + "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rq+FX55ne7lMiqai7NcvvDZj4HLsr+hg77WayqmySqc6zhw3tIOLxd4Ty6OpwNj0C0bVMi3iCl2zvJIEirh9XA=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="], "@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="], - "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.97", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s4tI7Z15i6FlbtCvS4SBRal8wRfkOXJzKxlS6cU4mJW/QfUfoVy4b22836NVNJwDvkG/HkDSfzwm/X8mn46MhA=="], + "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.98", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="], "@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="], @@ -596,11 +596,11 @@ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], - "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RlYubjStoZQxna4Ng91Vvo8YskvL7lW9zj68IwZfCnaDBSAp1u6Nhc5BR4ZtKnY6PA3XEtu4bATIQl7yiiQ+Lw=="], + "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="], - "@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="], + "@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="], - "@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="], + "@ai-sdk/xai": ["@ai-sdk/xai@2.0.56", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FGlqwWc3tAYqDHE8r8hQGQLcMiPUwgz90oU2QygUH930OWtCLapFkSu114DgVaIN/qoM1DUX+inv0Ee74Fgp5g=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -1240,7 +1240,7 @@ "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"], - "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.2", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "@toon-format/toon": "^2.0.0", "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" }, "optionalPeers": ["@toon-format/toon"] }, "sha512-3Th0vmJ9pjnwcPc2H1f59Mb0LFvwaREZAScfOQIpUxAHjZ7ZawVKDP27qgsteZPmMYqccNMy4r4Y3kgUnNcKAg=="], + "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="], "@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="], @@ -1932,7 +1932,7 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -1970,7 +1970,7 @@ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "ai": ["ai@5.0.119", "", { "dependencies": { "@ai-sdk/gateway": "2.0.25", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HUOwhc17fl2SZTJGZyA/99aNu706qKfXaUBCy9vgZiXBwrxg2eTzn2BCz7kmYDsfx6Fg2ACBy2icm41bsDXCTw=="], + "ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -4008,7 +4008,9 @@ "@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="], + + "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="], "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], @@ -4016,11 +4018,11 @@ "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="], - "@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + "@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], - "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], - "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="], + "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="], "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], @@ -4030,11 +4032,11 @@ "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], - "@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + "@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], - "@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + "@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], - "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], "@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], @@ -4414,11 +4416,11 @@ "nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], - "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="], + "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="], "opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="], - "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], diff --git a/flake.nix b/flake.nix index e4d214a0b9..ea78b1a434 100644 --- a/flake.nix +++ b/flake.nix @@ -42,28 +42,15 @@ desktop = pkgs.callPackage ./nix/desktop.nix { inherit opencode; }; - # nixpkgs cpu naming to bun cpu naming - cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; }; - # matrix of node_modules builds - these will always fail due to fakeHash usage - # but allow computation of the correct hash from any build machine for any cpu/os - # see the update-nix-hashes workflow for usage - moduleUpdaters = pkgs.lib.listToAttrs ( - pkgs.lib.concatMap (cpu: - map (os: { - name = "${cpu}-${os}_node_modules"; - value = node_modules.override { - bunCpu = cpuMap.${cpu}; - bunOs = os; - hash = pkgs.lib.fakeHash; - }; - }) [ "linux" "darwin" ] - ) [ "x86_64" "aarch64" ] - ); in { default = opencode; inherit opencode desktop; - } // moduleUpdaters + # Updater derivation with fakeHash - build fails and reveals correct hash + node_modules_updater = node_modules.override { + hash = pkgs.lib.fakeHash; + }; + } ); }; } diff --git a/infra/console.ts b/infra/console.ts index 5b08e9ceaa..ba1ff15bf2 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -133,6 +133,8 @@ const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS6"), new sst.Secret("ZEN_MODELS7"), new sst.Secret("ZEN_MODELS8"), + new sst.Secret("ZEN_MODELS9"), + new sst.Secret("ZEN_MODELS10"), ] const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") diff --git a/nix/hashes.json b/nix/hashes.json index f50c7a23df..23ef03d88b 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-gUWzUsk81miIrjg0fZQmsIQG4pZYmEHgzN6BaXI+lfc=", - "aarch64-linux": "sha256-gwEG75ha/ojTO2iAObTmLTtEkXIXJ7BThzfI5CqlJh8=", - "aarch64-darwin": "sha256-20RGG2GkUItCzD67gDdoSLfexttM8abS//FKO9bfjoM=", - "x86_64-darwin": "sha256-i2VawFuR1UbjPVYoybU6aJDJfFo0tcvtl1aM31Y2mTQ=" + "x86_64-linux": "sha256-3wRTDLo5FZoUc2Bwm1aAJZ4dNsekX8XoY6TwTmohgYo=", + "aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=", + "aarch64-darwin": "sha256-pWfXomWTDvG8WpWmUCwNXdbSHw6hPlqoT0Q/XuNceMc=", + "x86_64-darwin": "sha256-Dmg4+cUq2r6vZB2ta9tLpNAWqcl11ZCu4ZpieegRFrY=" } } diff --git a/nix/node_modules.nix b/nix/node_modules.nix index 981a60ef9b..6d75b9e750 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -2,8 +2,6 @@ lib, stdenvNoCC, bun, - bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64", - bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin", rev ? "dirty", hash ? (lib.pipe ./hashes.json [ @@ -16,6 +14,9 @@ let builtins.readFile builtins.fromJSON ]; + platform = stdenvNoCC.hostPlatform; + bunCpu = if platform.isAarch64 then "arm64" else "x64"; + bunOs = if platform.isLinux then "linux" else "darwin"; in stdenvNoCC.mkDerivation { pname = "opencode-node_modules"; @@ -39,9 +40,7 @@ stdenvNoCC.mkDerivation { "SOCKS_SERVER" ]; - nativeBuildInputs = [ - bun - ]; + nativeBuildInputs = [ bun ]; dontConfigure = true; @@ -63,10 +62,8 @@ stdenvNoCC.mkDerivation { installPhase = '' runHook preInstall - mkdir -p $out find . -type d -name node_modules -exec cp -R --parents {} $out \; - runHook postInstall ''; diff --git a/package.json b/package.json index 0686c705ee..04eabe1b76 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.12-a5629fb", "drizzle-orm": "1.0.0-beta.12-a5629fb", - "ai": "5.0.119", + "ai": "5.0.124", "hono": "4.10.7", "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts new file mode 100644 index 0000000000..7308adc7e6 --- /dev/null +++ b/packages/app/e2e/actions.ts @@ -0,0 +1,271 @@ +import { expect, type Locator, type Page } from "@playwright/test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { execSync } from "node:child_process" +import { modKey, serverUrl } from "./utils" +import { + sessionItemSelector, + dropdownMenuTriggerSelector, + dropdownMenuContentSelector, + titlebarRightSelector, + popoverBodySelector, + listItemSelector, + listItemKeySelector, + listItemKeyStartsWithSelector, +} from "./selectors" +import type { createSdk } from "./utils" + +export async function defocus(page: Page) { + await page.mouse.click(5, 5) +} + +export async function openPalette(page: Page) { + await defocus(page) + await page.keyboard.press(`${modKey}+P`) + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("textbox").first()).toBeVisible() + return dialog +} + +export async function closeDialog(page: Page, dialog: Locator) { + await page.keyboard.press("Escape") + const closed = await dialog + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (closed) return + + await page.keyboard.press("Escape") + const closedSecond = await dialog + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (closedSecond) return + + await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) + await expect(dialog).toHaveCount(0) +} + +export async function isSidebarClosed(page: Page) { + const main = page.locator("main") + const classes = (await main.getAttribute("class")) ?? "" + return classes.includes("xl:border-l") +} + +export async function toggleSidebar(page: Page) { + await defocus(page) + await page.keyboard.press(`${modKey}+B`) +} + +export async function openSidebar(page: Page) { + if (!(await isSidebarClosed(page))) return + await toggleSidebar(page) + await expect(page.locator("main")).not.toHaveClass(/xl:border-l/) +} + +export async function closeSidebar(page: Page) { + if (await isSidebarClosed(page)) return + await toggleSidebar(page) + await expect(page.locator("main")).toHaveClass(/xl:border-l/) +} + +export async function openSettings(page: Page) { + await defocus(page) + + const dialog = page.getByRole("dialog") + await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) + + const opened = await dialog + .waitFor({ state: "visible", timeout: 3000 }) + .then(() => true) + .catch(() => false) + + if (opened) return dialog + + await page.getByRole("button", { name: "Settings" }).first().click() + await expect(dialog).toBeVisible() + return dialog +} + +export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) { + await page.addInitScript( + (args: { directory: string; serverUrl: string; extra: string[] }) => { + const key = "opencode.global.dat:server" + const raw = localStorage.getItem(key) + const parsed = (() => { + if (!raw) return undefined + try { + return JSON.parse(raw) as unknown + } catch { + return undefined + } + })() + + const store = parsed && typeof parsed === "object" ? (parsed as Record) : {} + const list = Array.isArray(store.list) ? store.list : [] + const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} + const projects = store.projects && typeof store.projects === "object" ? store.projects : {} + const nextProjects = { ...(projects as Record) } + + const add = (origin: string, directory: string) => { + const current = nextProjects[origin] + const items = Array.isArray(current) ? current : [] + const existing = items.filter( + (p): p is { worktree: string; expanded?: boolean } => + !!p && + typeof p === "object" && + "worktree" in p && + typeof (p as { worktree?: unknown }).worktree === "string", + ) + + if (existing.some((p) => p.worktree === directory)) return + nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing] + } + + const directories = [args.directory, ...args.extra] + for (const directory of directories) { + add("local", directory) + add(args.serverUrl, directory) + } + + localStorage.setItem( + key, + JSON.stringify({ + list, + projects: nextProjects, + lastProject, + }), + ) + }, + { directory: input.directory, serverUrl, extra: input.extra ?? [] }, + ) +} + +export async function createTestProject() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) + + await fs.writeFile(path.join(root, "README.md"), "# e2e\n") + + execSync("git init", { cwd: root, stdio: "ignore" }) + execSync("git add -A", { cwd: root, stdio: "ignore" }) + execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { + cwd: root, + stdio: "ignore", + }) + + return root +} + +export async function cleanupTestProject(directory: string) { + await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined) +} + +export function sessionIDFromUrl(url: string) { + const match = /\/session\/([^/?#]+)/.exec(url) + return match?.[1] +} + +export async function hoverSessionItem(page: Page, sessionID: string) { + const sessionEl = page.locator(sessionItemSelector(sessionID)).first() + await expect(sessionEl).toBeVisible() + await sessionEl.hover() + return sessionEl +} + +export async function openSessionMoreMenu(page: Page, sessionID: string) { + const sessionEl = await hoverSessionItem(page, sessionID) + + const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first() + await expect(menuTrigger).toBeVisible() + await menuTrigger.click() + + const menu = page.locator(dropdownMenuContentSelector).first() + await expect(menu).toBeVisible() + return menu +} + +export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) { + const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first() + await expect(item).toBeVisible() + await item.click({ force: options?.force }) +} + +export async function confirmDialog(page: Page, buttonName: string | RegExp) { + const dialog = page.getByRole("dialog").first() + await expect(dialog).toBeVisible() + + const button = dialog.getByRole("button").filter({ hasText: buttonName }).first() + await expect(button).toBeVisible() + await button.click() +} + +export async function openSharePopover(page: Page) { + const rightSection = page.locator(titlebarRightSelector) + const shareButton = rightSection.getByRole("button", { name: "Share" }).first() + await expect(shareButton).toBeVisible() + + const popoverBody = page + .locator(popoverBodySelector) + .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) }) + .first() + + const opened = await popoverBody + .isVisible() + .then((x) => x) + .catch(() => false) + + if (!opened) { + await shareButton.click() + await expect(popoverBody).toBeVisible() + } + return { rightSection, popoverBody } +} + +export async function clickPopoverButton(page: Page, buttonName: string | RegExp) { + const button = page.getByRole("button").filter({ hasText: buttonName }).first() + await expect(button).toBeVisible() + await button.click() +} + +export async function clickListItem( + container: Locator | Page, + filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string }, +): Promise { + let item: Locator + + if (typeof filter === "string" || filter instanceof RegExp) { + item = container.locator(listItemSelector).filter({ hasText: filter }).first() + } else if (filter.keyStartsWith) { + item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first() + } else if (filter.key) { + item = container.locator(listItemKeySelector(filter.key)).first() + } else if (filter.text) { + item = container.locator(listItemSelector).filter({ hasText: filter.text }).first() + } else { + throw new Error("Invalid filter provided to clickListItem") + } + + await expect(item).toBeVisible() + await item.click() + return item +} + +export async function withSession( + sdk: ReturnType, + title: string, + callback: (session: { id: string; title: string }) => Promise, +): Promise { + const session = await sdk.session.create({ title }).then((r) => r.data) + if (!session?.id) throw new Error("Session create did not return an id") + + try { + return await callback(session) + } finally { + await sdk.session.delete({ sessionID: session.id }).catch(() => undefined) + } +} diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/app/home.spec.ts similarity index 88% rename from packages/app/e2e/home.spec.ts rename to packages/app/e2e/app/home.spec.ts index c6fb0e3b07..f21dc40ec2 100644 --- a/packages/app/e2e/home.spec.ts +++ b/packages/app/e2e/app/home.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { serverName } from "./utils" +import { test, expect } from "../fixtures" +import { serverName } from "../utils" test("home renders and shows core entrypoints", async ({ page }) => { await page.goto("/") diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/app/navigation.spec.ts similarity index 66% rename from packages/app/e2e/navigation.spec.ts rename to packages/app/e2e/app/navigation.spec.ts index 76923af6ed..328c950df3 100644 --- a/packages/app/e2e/navigation.spec.ts +++ b/packages/app/e2e/app/navigation.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { dirPath, promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { dirPath } from "../utils" test("project route redirects to /session", async ({ page, directory, slug }) => { await page.goto(dirPath(directory)) diff --git a/packages/app/e2e/app/palette.spec.ts b/packages/app/e2e/app/palette.spec.ts new file mode 100644 index 0000000000..3ccfd7a925 --- /dev/null +++ b/packages/app/e2e/app/palette.spec.ts @@ -0,0 +1,11 @@ +import { test, expect } from "../fixtures" +import { openPalette } from "../actions" + +test("search palette opens and closes", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openPalette(page) + + await page.keyboard.press("Escape") + await expect(dialog).toHaveCount(0) +}) diff --git a/packages/app/e2e/server-default.spec.ts b/packages/app/e2e/app/server-default.spec.ts similarity index 64% rename from packages/app/e2e/server-default.spec.ts rename to packages/app/e2e/app/server-default.spec.ts index b6b16f0bcc..adbc83473b 100644 --- a/packages/app/e2e/server-default.spec.ts +++ b/packages/app/e2e/app/server-default.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { serverName, serverUrl } from "./utils" +import { test, expect } from "../fixtures" +import { serverName, serverUrl } from "../utils" +import { clickListItem, closeDialog, clickMenuItem } from "../actions" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" @@ -33,31 +34,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => { const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first() await expect(row).toBeVisible() - const menu = row.locator('[data-component="icon-button"]').last() - await menu.click() - await page.getByRole("menuitem", { name: "Set as default" }).click() + const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first() + await expect(menuTrigger).toBeVisible() + await menuTrigger.click({ force: true }) + + const menu = page.locator('[data-component="dropdown-menu-content"]').first() + await expect(menu).toBeVisible() + await clickMenuItem(menu, /set as default/i) await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl) await expect(row.getByText("Default", { exact: true })).toBeVisible() - await page.keyboard.press("Escape") - const closed = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (!closed) { - await page.keyboard.press("Escape") - const closedSecond = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (!closedSecond) { - await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) - await expect(dialog).toHaveCount(0) - } - } + await closeDialog(page, dialog) await ensurePopoverOpen() diff --git a/packages/app/e2e/app/session.spec.ts b/packages/app/e2e/app/session.spec.ts new file mode 100644 index 0000000000..c7fdfdc542 --- /dev/null +++ b/packages/app/e2e/app/session.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { withSession } from "../actions" + +test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => { + const title = `e2e smoke ${Date.now()}` + + await withSession(sdk, title, async (session) => { + await gotoSession(session.id) + + const prompt = page.locator(promptSelector) + await prompt.click() + await page.keyboard.type("hello from e2e") + await expect(prompt).toContainText("hello from e2e") + }) +}) diff --git a/packages/app/e2e/app/titlebar-history.spec.ts b/packages/app/e2e/app/titlebar-history.spec.ts new file mode 100644 index 0000000000..ec65dca0b3 --- /dev/null +++ b/packages/app/e2e/app/titlebar-history.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "../fixtures" +import { openSidebar, withSession } from "../actions" +import { promptSelector } from "../selectors" + +test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const stamp = Date.now() + + await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => { + await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => { + await gotoSession(one.id) + + await openSidebar(page) + + const link = page.locator(`[data-session-id="${two.id}"] a`).first() + await expect(link).toBeVisible() + await link.scrollIntoViewIfNeeded() + await link.click() + + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) + await expect(page.locator(promptSelector)).toBeVisible() + + const back = page.getByRole("button", { name: "Back" }) + const forward = page.getByRole("button", { name: "Forward" }) + + await expect(back).toBeVisible() + await expect(back).toBeEnabled() + await back.click() + + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`)) + await expect(page.locator(promptSelector)).toBeVisible() + + await expect(forward).toBeVisible() + await expect(forward).toBeEnabled() + await forward.click() + + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) + await expect(page.locator(promptSelector)).toBeVisible() + }) + }) +}) diff --git a/packages/app/e2e/file-open.spec.ts b/packages/app/e2e/files/file-open.spec.ts similarity index 53% rename from packages/app/e2e/file-open.spec.ts rename to packages/app/e2e/files/file-open.spec.ts index fb7104b6b0..3c636d748a 100644 --- a/packages/app/e2e/file-open.spec.ts +++ b/packages/app/e2e/files/file-open.spec.ts @@ -1,20 +1,15 @@ -import { test, expect } from "./fixtures" -import { modKey } from "./utils" +import { test, expect } from "../fixtures" +import { openPalette, clickListItem } from "../actions" test("can open a file tab from the search palette", async ({ page, gotoSession }) => { await gotoSession() - await page.keyboard.press(`${modKey}+P`) - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() + const dialog = await openPalette(page) const input = dialog.getByRole("textbox").first() await input.fill("package.json") - const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first() - await expect(fileItem).toBeVisible() - await fileItem.click() + await clickListItem(dialog, { keyStartsWith: "file:" }) await expect(dialog).toHaveCount(0) diff --git a/packages/app/e2e/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts similarity index 96% rename from packages/app/e2e/file-tree.spec.ts rename to packages/app/e2e/files/file-tree.spec.ts index 0b04eb2468..844da1b329 100644 --- a/packages/app/e2e/file-tree.spec.ts +++ b/packages/app/e2e/files/file-tree.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "./fixtures" +import { test, expect } from "../fixtures" test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/file-viewer.spec.ts b/packages/app/e2e/files/file-viewer.spec.ts similarity index 59% rename from packages/app/e2e/file-viewer.spec.ts rename to packages/app/e2e/files/file-viewer.spec.ts index 1e0f8a6f23..5283844975 100644 --- a/packages/app/e2e/file-viewer.spec.ts +++ b/packages/app/e2e/files/file-viewer.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { modKey } from "./utils" +import { test, expect } from "../fixtures" +import { openPalette, clickListItem } from "../actions" test("smoke file viewer renders real file content", async ({ page, gotoSession }) => { await gotoSession() @@ -7,21 +7,12 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession } const sep = process.platform === "win32" ? "\\" : "/" const file = ["packages", "app", "package.json"].join(sep) - await page.keyboard.press(`${modKey}+P`) - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() + const dialog = await openPalette(page) const input = dialog.getByRole("textbox").first() await input.fill(file) - const fileItem = dialog - .locator( - '[data-slot="list-item"][data-key^="file:"][data-key*="packages"][data-key*="app"][data-key$="package.json"]', - ) - .first() - await expect(fileItem).toBeVisible() - await fileItem.click() + await clickListItem(dialog, { text: /packages.*app.*package.json/ }) await expect(dialog).toHaveCount(0) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index c5315ff194..0c3150609b 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,5 +1,7 @@ import { test as base, expect } from "@playwright/test" -import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils" +import { seedProjects } from "./actions" +import { promptSelector } from "./selectors" +import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" type TestFixtures = { sdk: ReturnType @@ -29,54 +31,17 @@ export const test = base.extend({ await use(createSdk(directory)) }, gotoSession: async ({ page, directory }, use) => { - await page.addInitScript( - (input: { directory: string; serverUrl: string }) => { - const key = "opencode.global.dat:server" - const raw = localStorage.getItem(key) - const parsed = (() => { - if (!raw) return undefined - try { - return JSON.parse(raw) as unknown - } catch { - return undefined - } - })() - - const store = parsed && typeof parsed === "object" ? (parsed as Record) : {} - const list = Array.isArray(store.list) ? store.list : [] - const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} - const projects = store.projects && typeof store.projects === "object" ? store.projects : {} - const nextProjects = { ...(projects as Record) } - - const add = (origin: string) => { - const current = nextProjects[origin] - const items = Array.isArray(current) ? current : [] - const existing = items.filter( - (p): p is { worktree: string; expanded?: boolean } => - !!p && - typeof p === "object" && - "worktree" in p && - typeof (p as { worktree?: unknown }).worktree === "string", - ) - - if (existing.some((p) => p.worktree === input.directory)) return - nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing] - } - - add("local") - add(input.serverUrl) - - localStorage.setItem( - key, - JSON.stringify({ - list, - projects: nextProjects, - lastProject, - }), - ) - }, - { directory, serverUrl }, - ) + await seedProjects(page, { directory }) + await page.addInitScript(() => { + localStorage.setItem( + "opencode.global.dat:model", + JSON.stringify({ + recent: [{ providerID: "opencode", modelID: "big-pickle" }], + user: [], + variant: {}, + }), + ) + }) const gotoSession = async (sessionID?: string) => { await page.goto(sessionPath(directory, sessionID)) diff --git a/packages/app/e2e/model-picker.spec.ts b/packages/app/e2e/models/model-picker.spec.ts similarity index 85% rename from packages/app/e2e/model-picker.spec.ts rename to packages/app/e2e/models/model-picker.spec.ts index 9e64b3dfb0..01e72464cc 100644 --- a/packages/app/e2e/model-picker.spec.ts +++ b/packages/app/e2e/models/model-picker.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { clickListItem } from "../actions" test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => { await gotoSession() @@ -32,9 +33,7 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession } await input.fill(model) - const item = dialog.locator(`[data-slot="list-item"][data-key="${key}"]`) - await expect(item).toBeVisible() - await item.click() + await clickListItem(dialog, { key }) await expect(dialog).toHaveCount(0) diff --git a/packages/app/e2e/models-visibility.spec.ts b/packages/app/e2e/models/models-visibility.spec.ts similarity index 66% rename from packages/app/e2e/models-visibility.spec.ts rename to packages/app/e2e/models/models-visibility.spec.ts index 680ba96a31..c699111793 100644 --- a/packages/app/e2e/models-visibility.spec.ts +++ b/packages/app/e2e/models/models-visibility.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { modKey, promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { closeDialog, openSettings, clickListItem } from "../actions" test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { await gotoSession() @@ -27,18 +28,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi await page.keyboard.press("Escape") await expect(picker).toHaveCount(0) - const settings = page.getByRole("dialog") - - await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) - const opened = await settings - .waitFor({ state: "visible", timeout: 3000 }) - .then(() => true) - .catch(() => false) - - if (!opened) { - await page.getByRole("button", { name: "Settings" }).first().click() - await expect(settings).toBeVisible() - } + const settings = await openSettings(page) await settings.getByRole("tab", { name: "Models" }).click() const search = settings.getByPlaceholder("Search models") @@ -52,22 +42,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi await toggle.locator('[data-slot="switch-control"]').click() await expect(input).toHaveAttribute("aria-checked", "false") - await page.keyboard.press("Escape") - const closed = await settings - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - if (!closed) { - await page.keyboard.press("Escape") - const closedSecond = await settings - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - if (!closedSecond) { - await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) - await expect(settings).toHaveCount(0) - } - } + await closeDialog(page, settings) await page.locator(promptSelector).click() await page.keyboard.type("/model") diff --git a/packages/app/e2e/palette.spec.ts b/packages/app/e2e/palette.spec.ts deleted file mode 100644 index 617c55ac16..0000000000 --- a/packages/app/e2e/palette.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { test, expect } from "./fixtures" -import { modKey } from "./utils" - -test("search palette opens and closes", async ({ page, gotoSession }) => { - await gotoSession() - - await page.keyboard.press(`${modKey}+P`) - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("textbox").first()).toBeVisible() - - await page.keyboard.press("Escape") - await expect(dialog).toHaveCount(0) -}) diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts new file mode 100644 index 0000000000..772c259517 --- /dev/null +++ b/packages/app/e2e/projects/project-edit.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "../fixtures" +import { openSidebar } from "../actions" + +test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => { + await gotoSession() + await page.setViewportSize({ width: 1400, height: 800 }) + + await openSidebar(page) + + const open = async () => { + const header = page.locator(".group\\/project").first() + await header.hover() + const trigger = header.getByRole("button", { name: "More options" }).first() + await expect(trigger).toBeVisible() + await trigger.click({ force: true }) + + const menu = page.locator('[data-component="dropdown-menu-content"]').first() + await expect(menu).toBeVisible() + + const editItem = menu.getByRole("menuitem", { name: "Edit" }).first() + await expect(editItem).toBeVisible() + await editItem.click({ force: true }) + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") + return dialog + } + + const name = `e2e project ${Date.now()}` + const startup = `echo e2e_${Date.now()}` + + const dialog = await open() + + const nameInput = dialog.getByLabel("Name") + await nameInput.fill(name) + + const startupInput = dialog.getByLabel("Workspace startup script") + await startupInput.fill(startup) + + await dialog.getByRole("button", { name: "Save" }).click() + await expect(dialog).toHaveCount(0) + + const header = page.locator(".group\\/project").first() + await expect(header).toContainText(name) + + const reopened = await open() + await expect(reopened.getByLabel("Name")).toHaveValue(name) + await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) + await reopened.getByRole("button", { name: "Cancel" }).click() + await expect(reopened).toHaveCount(0) +}) diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts new file mode 100644 index 0000000000..bd323b90c6 --- /dev/null +++ b/packages/app/e2e/projects/projects-close.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "../fixtures" +import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions" +import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors" +import { dirSlug } from "../utils" + +test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const other = await createTestProject() + const otherSlug = dirSlug(other) + await seedProjects(page, { directory, extra: [other] }) + + try { + await gotoSession() + + await openSidebar(page) + + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.hover() + + const close = page.locator(projectCloseHoverSelector(otherSlug)).first() + await expect(close).toBeVisible() + await close.click() + + await expect(otherButton).toHaveCount(0) + } finally { + await cleanupTestProject(other) + } +}) + +test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const other = await createTestProject() + const otherName = other.split("/").pop() ?? other + const otherSlug = dirSlug(other) + await seedProjects(page, { directory, extra: [other] }) + + try { + await gotoSession() + + await openSidebar(page) + + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() + + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + + const header = page + .locator(".group\\/project") + .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) }) + .first() + await expect(header).toContainText(otherName) + + const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first() + await expect(trigger).toHaveCount(1) + await trigger.focus() + await page.keyboard.press("Enter") + + const menu = page.locator('[data-component="dropdown-menu-content"]').first() + await expect(menu).toBeVisible({ timeout: 10_000 }) + + await clickMenuItem(menu, /^Close$/i, { force: true }) + await expect(otherButton).toHaveCount(0) + } finally { + await cleanupTestProject(other) + } +}) diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts new file mode 100644 index 0000000000..829ed8e57d --- /dev/null +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "../fixtures" +import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions" +import { projectSwitchSelector } from "../selectors" +import { dirSlug } from "../utils" + +test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const other = await createTestProject() + const otherSlug = dirSlug(other) + + await seedProjects(page, { directory, extra: [other] }) + + try { + await gotoSession() + + await defocus(page) + + const currentSlug = dirSlug(directory) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() + + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + + const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() + await expect(currentButton).toBeVisible() + await currentButton.click() + + await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) + } finally { + await cleanupTestProject(other) + } +}) diff --git a/packages/app/e2e/context.spec.ts b/packages/app/e2e/prompt/context.spec.ts similarity index 61% rename from packages/app/e2e/context.spec.ts rename to packages/app/e2e/prompt/context.spec.ts index beabd2eb7d..80aa9ea334 100644 --- a/packages/app/e2e/context.spec.ts +++ b/packages/app/e2e/prompt/context.spec.ts @@ -1,16 +1,13 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { withSession } from "../actions" test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => { const title = `e2e smoke context ${Date.now()}` - const created = await sdk.session.create({ title }).then((r) => r.data) - if (!created?.id) throw new Error("Session create did not return an id") - const sessionID = created.id - - try { + await withSession(sdk, title, async (session) => { await sdk.session.promptAsync({ - sessionID, + sessionID: session.id, noReply: true, parts: [ { @@ -22,12 +19,12 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess await expect .poll(async () => { - const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? []) return messages.length }) .toBeGreaterThan(0) - await gotoSession(sessionID) + await gotoSession(session.id) const contextButton = page .locator('[data-component="button"]') @@ -39,7 +36,5 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() - } finally { - await sdk.session.delete({ sessionID }).catch(() => undefined) - } + }) }) diff --git a/packages/app/e2e/prompt-mention.spec.ts b/packages/app/e2e/prompt/prompt-mention.spec.ts similarity index 90% rename from packages/app/e2e/prompt-mention.spec.ts rename to packages/app/e2e/prompt/prompt-mention.spec.ts index 113b8465f7..5cc9f6e685 100644 --- a/packages/app/e2e/prompt-mention.spec.ts +++ b/packages/app/e2e/prompt/prompt-mention.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/prompt-slash-open.spec.ts b/packages/app/e2e/prompt/prompt-slash-open.spec.ts similarity index 86% rename from packages/app/e2e/prompt-slash-open.spec.ts rename to packages/app/e2e/prompt/prompt-slash-open.spec.ts index 3c29d405c1..b4a93099d9 100644 --- a/packages/app/e2e/prompt-slash-open.spec.ts +++ b/packages/app/e2e/prompt/prompt-slash-open.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts similarity index 89% rename from packages/app/e2e/prompt.spec.ts rename to packages/app/e2e/prompt/prompt.spec.ts index 3e5892ce8d..07d242c634 100644 --- a/packages/app/e2e/prompt.spec.ts +++ b/packages/app/e2e/prompt/prompt.spec.ts @@ -1,10 +1,6 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" - -function sessionIDFromUrl(url: string) { - const match = /\/session\/([^/?#]+)/.exec(url) - return match?.[1] -} +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { sessionIDFromUrl, withSession } from "../actions" test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => { test.setTimeout(120_000) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts new file mode 100644 index 0000000000..8beade5730 --- /dev/null +++ b/packages/app/e2e/selectors.ts @@ -0,0 +1,35 @@ +export const promptSelector = '[data-component="prompt-input"]' +export const terminalSelector = '[data-component="terminal"]' + +export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' +export const settingsLanguageSelectSelector = '[data-action="settings-language"]' + +export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]' + +export const projectSwitchSelector = (slug: string) => + `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]` + +export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]` + +export const projectMenuTriggerSelector = (slug: string) => + `${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]` + +export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` + +export const titlebarRightSelector = "#opencode-titlebar-right" + +export const popoverBodySelector = '[data-slot="popover-body"]' + +export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]' + +export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]' + +export const inlineInputSelector = '[data-component="inline-input"]' + +export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` + +export const listItemSelector = '[data-slot="list-item"]' + +export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]` + +export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]` diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/session.spec.ts deleted file mode 100644 index 19e25a4213..0000000000 --- a/packages/app/e2e/session.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" - -test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => { - const title = `e2e smoke ${Date.now()}` - const created = await sdk.session.create({ title }).then((r) => r.data) - - if (!created?.id) throw new Error("Session create did not return an id") - const sessionID = created.id - - try { - await gotoSession(sessionID) - - const prompt = page.locator(promptSelector) - await prompt.click() - await page.keyboard.type("hello from e2e") - await expect(prompt).toContainText("hello from e2e") - } finally { - await sdk.session.delete({ sessionID }).catch(() => undefined) - } -}) diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts new file mode 100644 index 0000000000..05984bbeee --- /dev/null +++ b/packages/app/e2e/session/session.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from "../fixtures" +import { + openSidebar, + openSessionMoreMenu, + clickMenuItem, + confirmDialog, + openSharePopover, + withSession, +} from "../actions" +import { sessionItemSelector, inlineInputSelector } from "../selectors" + +const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" + +test("sidebar session can be renamed", async ({ page, sdk, gotoSession }) => { + const stamp = Date.now() + const originalTitle = `e2e rename test ${stamp}` + const newTitle = `e2e renamed ${stamp}` + + await withSession(sdk, originalTitle, async (session) => { + await gotoSession(session.id) + await openSidebar(page) + + const menu = await openSessionMoreMenu(page, session.id) + await clickMenuItem(menu, /rename/i) + + const input = page.locator(sessionItemSelector(session.id)).locator(inlineInputSelector).first() + await expect(input).toBeVisible() + await input.fill(newTitle) + await input.press("Enter") + + await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle) + }) +}) + +test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => { + const stamp = Date.now() + const title = `e2e archive test ${stamp}` + + await withSession(sdk, title, async (session) => { + await gotoSession(session.id) + await openSidebar(page) + + const sessionEl = page.locator(sessionItemSelector(session.id)) + const menu = await openSessionMoreMenu(page, session.id) + await clickMenuItem(menu, /archive/i) + + await expect(sessionEl).not.toBeVisible() + }) +}) + +test("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => { + const stamp = Date.now() + const title = `e2e delete test ${stamp}` + + await withSession(sdk, title, async (session) => { + await gotoSession(session.id) + await openSidebar(page) + + const sessionEl = page.locator(sessionItemSelector(session.id)) + const menu = await openSessionMoreMenu(page, session.id) + await clickMenuItem(menu, /delete/i) + await confirmDialog(page, /delete/i) + + await expect(sessionEl).not.toBeVisible() + }) +}) + +test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => { + test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") + + const stamp = Date.now() + const title = `e2e share test ${stamp}` + + await withSession(sdk, title, async (session) => { + await gotoSession(session.id) + + const { rightSection, popoverBody } = await openSharePopover(page) + await popoverBody.getByRole("button", { name: "Publish" }).first().click() + + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() + + const copyButton = rightSection.locator('button[aria-label="Copy link"]').first() + await expect(copyButton).toBeVisible({ timeout: 30_000 }) + + const sharedPopover = await openSharePopover(page) + const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first() + await expect(unpublish).toBeVisible({ timeout: 30_000 }) + await unpublish.click() + + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .toBeUndefined() + + await expect(copyButton).not.toBeVisible({ timeout: 30_000 }) + + const unsharedPopover = await openSharePopover(page) + await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ + timeout: 30_000, + }) + }) +}) diff --git a/packages/app/e2e/settings.spec.ts b/packages/app/e2e/settings.spec.ts deleted file mode 100644 index 09dc942cc9..0000000000 --- a/packages/app/e2e/settings.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { test, expect } from "./fixtures" -import { modKey } from "./utils" - -test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => { - await gotoSession() - - const dialog = page.getByRole("dialog") - - await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) - - const opened = await dialog - .waitFor({ state: "visible", timeout: 3000 }) - .then(() => true) - .catch(() => false) - - if (!opened) { - await page.getByRole("button", { name: "Settings" }).first().click() - await expect(dialog).toBeVisible() - } - - await dialog.getByRole("tab", { name: "Shortcuts" }).click() - await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible() - await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible() - - await page.keyboard.press("Escape") - - const closed = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (closed) return - - await page.keyboard.press("Escape") - const closedSecond = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (closedSecond) return - - await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) - await expect(dialog).toHaveCount(0) -}) diff --git a/packages/app/e2e/settings/settings-language.spec.ts b/packages/app/e2e/settings/settings-language.spec.ts new file mode 100644 index 0000000000..b326a7d81c --- /dev/null +++ b/packages/app/e2e/settings/settings-language.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "../fixtures" +import { settingsLanguageSelectSelector } from "../selectors" +import { openSettings } from "../actions" + +test("smoke changing language updates settings labels", async ({ page, gotoSession }) => { + await page.addInitScript(() => { + localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" })) + }) + + await gotoSession() + + const dialog = await openSettings(page) + + const heading = dialog.getByRole("heading", { level: 2 }) + await expect(heading).toHaveText("General") + + const select = dialog.locator(settingsLanguageSelectSelector) + await expect(select).toBeVisible() + await select.locator('[data-slot="select-select-trigger"]').click() + + await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click() + + await expect(heading).toHaveText("Allgemein") + + await select.locator('[data-slot="select-select-trigger"]').click() + await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click() + await expect(heading).toHaveText("General") +}) diff --git a/packages/app/e2e/settings-providers.spec.ts b/packages/app/e2e/settings/settings-providers.spec.ts similarity index 50% rename from packages/app/e2e/settings-providers.spec.ts rename to packages/app/e2e/settings/settings-providers.spec.ts index 326a9fad1d..2bd2616bc9 100644 --- a/packages/app/e2e/settings-providers.spec.ts +++ b/packages/app/e2e/settings/settings-providers.spec.ts @@ -1,22 +1,11 @@ -import { test, expect } from "./fixtures" -import { modKey, promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { closeDialog, openSettings, clickListItem } from "../actions" test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => { await gotoSession() - const dialog = page.getByRole("dialog") - - await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) - - const opened = await dialog - .waitFor({ state: "visible", timeout: 3000 }) - .then(() => true) - .catch(() => false) - - if (!opened) { - await page.getByRole("button", { name: "Settings" }).first().click() - await expect(dialog).toBeVisible() - } + const dialog = await openSettings(page) await dialog.getByRole("tab", { name: "Providers" }).click() await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible() @@ -37,20 +26,5 @@ test("smoke providers settings opens provider selector", async ({ page, gotoSess const stillOpen = await dialog.isVisible().catch(() => false) if (!stillOpen) return - await page.keyboard.press("Escape") - const closed = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - if (closed) return - - await page.keyboard.press("Escape") - const closedSecond = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - if (closedSecond) return - - await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) - await expect(dialog).toHaveCount(0) + await closeDialog(page, dialog) }) diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts new file mode 100644 index 0000000000..55b7670763 --- /dev/null +++ b/packages/app/e2e/settings/settings.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from "../fixtures" +import { closeDialog, openSettings } from "../actions" + +test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + + await dialog.getByRole("tab", { name: "Shortcuts" }).click() + await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible() + await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible() + + await closeDialog(page, dialog) +}) diff --git a/packages/app/e2e/sidebar.spec.ts b/packages/app/e2e/sidebar.spec.ts deleted file mode 100644 index 925590f510..0000000000 --- a/packages/app/e2e/sidebar.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { test, expect } from "./fixtures" -import { modKey } from "./utils" - -test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { - await gotoSession() - - const main = page.locator("main") - const closedClass = /xl:border-l/ - const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l")) - - if (isClosed) { - await page.keyboard.press(`${modKey}+B`) - await expect(main).not.toHaveClass(closedClass) - } - - await page.keyboard.press(`${modKey}+B`) - await expect(main).toHaveClass(closedClass) - - await page.keyboard.press(`${modKey}+B`) - await expect(main).not.toHaveClass(closedClass) -}) diff --git a/packages/app/e2e/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts similarity index 52% rename from packages/app/e2e/sidebar-session-links.spec.ts rename to packages/app/e2e/sidebar/sidebar-session-links.spec.ts index fab64736e2..cda2278a95 100644 --- a/packages/app/e2e/sidebar-session-links.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts @@ -1,33 +1,8 @@ -import { test, expect } from "./fixtures" -import { modKey, promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { openSidebar, withSession } from "../actions" +import { promptSelector } from "../selectors" -type Locator = { - first: () => Locator - getAttribute: (name: string) => Promise - scrollIntoViewIfNeeded: () => Promise - click: () => Promise -} - -type Page = { - locator: (selector: string) => Locator - keyboard: { - press: (key: string) => Promise - } -} - -type Fixtures = { - page: Page - slug: string - sdk: { - session: { - create: (input: { title: string }) => Promise<{ data?: { id?: string } }> - delete: (input: { sessionID: string }) => Promise - } - } - gotoSession: (sessionID?: string) => Promise -} - -test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }: Fixtures) => { +test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => { const stamp = Date.now() const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data) @@ -39,12 +14,7 @@ test("sidebar session links navigate to the selected session", async ({ page, sl try { await gotoSession(one.id) - const main = page.locator("main") - const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l") - if (collapsed) { - await page.keyboard.press(`${modKey}+B`) - await expect(main).not.toHaveClass(/xl:border-l/) - } + await openSidebar(page) const target = page.locator(`[data-session-id="${two.id}"] a`).first() await expect(target).toBeVisible() diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts new file mode 100644 index 0000000000..6239a04bd7 --- /dev/null +++ b/packages/app/e2e/sidebar/sidebar.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from "../fixtures" +import { openSidebar, toggleSidebar } from "../actions" + +test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { + await gotoSession() + + await openSidebar(page) + + await toggleSidebar(page) + await expect(page.locator("main")).toHaveClass(/xl:border-l/) + + await toggleSidebar(page) + await expect(page.locator("main")).not.toHaveClass(/xl:border-l/) +}) diff --git a/packages/app/e2e/terminal-init.spec.ts b/packages/app/e2e/terminal/terminal-init.spec.ts similarity index 83% rename from packages/app/e2e/terminal-init.spec.ts rename to packages/app/e2e/terminal/terminal-init.spec.ts index cfde2d0193..87934b66e3 100644 --- a/packages/app/e2e/terminal-init.spec.ts +++ b/packages/app/e2e/terminal/terminal-init.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { promptSelector, terminalSelector, terminalToggleKey } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector, terminalSelector } from "../selectors" +import { terminalToggleKey } from "../utils" test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal/terminal.spec.ts similarity index 74% rename from packages/app/e2e/terminal.spec.ts rename to packages/app/e2e/terminal/terminal.spec.ts index fc558b6325..ef88aa34e5 100644 --- a/packages/app/e2e/terminal.spec.ts +++ b/packages/app/e2e/terminal/terminal.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { terminalSelector, terminalToggleKey } from "./utils" +import { test, expect } from "../fixtures" +import { terminalSelector } from "../selectors" +import { terminalToggleKey } from "../utils" test("terminal panel can be toggled", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/thinking-level.spec.ts b/packages/app/e2e/thinking-level.spec.ts index 564ef3c1f3..92200933e5 100644 --- a/packages/app/e2e/thinking-level.spec.ts +++ b/packages/app/e2e/thinking-level.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "./fixtures" -import { modelVariantCycleSelector } from "./utils" +import { modelVariantCycleSelector } from "./selectors" test("smoke model variant cycle updates label", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/titlebar-history.spec.ts b/packages/app/e2e/titlebar-history.spec.ts deleted file mode 100644 index d4aa605e6d..0000000000 --- a/packages/app/e2e/titlebar-history.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { test, expect } from "./fixtures" -import { modKey, promptSelector } from "./utils" - -test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const stamp = Date.now() - const one = await sdk.session.create({ title: `e2e titlebar history 1 ${stamp}` }).then((r) => r.data) - const two = await sdk.session.create({ title: `e2e titlebar history 2 ${stamp}` }).then((r) => r.data) - - if (!one?.id) throw new Error("Session create did not return an id") - if (!two?.id) throw new Error("Session create did not return an id") - - try { - await gotoSession(one.id) - - const main = page.locator("main") - const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l") - if (collapsed) { - await page.keyboard.press(`${modKey}+B`) - await expect(main).not.toHaveClass(/xl:border-l/) - } - - const link = page.locator(`[data-session-id="${two.id}"] a`).first() - await expect(link).toBeVisible() - await link.scrollIntoViewIfNeeded() - await link.click() - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - - const back = page.getByRole("button", { name: "Back" }) - const forward = page.getByRole("button", { name: "Forward" }) - - await expect(back).toBeVisible() - await expect(back).toBeEnabled() - await back.click() - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - - await expect(forward).toBeVisible() - await expect(forward).toBeEnabled() - await forward.click() - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - } finally { - await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) - await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) - } -}) diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 76438a03cc..18e88ddc9c 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../tsconfig.json", "compilerOptions": { "noEmit": true, - "types": ["node"] + "types": ["node", "bun"] }, "include": ["./**/*.ts"] } diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index 3de488bd9c..ec6cdf8302 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -10,10 +10,6 @@ export const serverName = `${serverHost}:${serverPort}` export const modKey = process.platform === "darwin" ? "Meta" : "Control" export const terminalToggleKey = "Control+Backquote" -export const promptSelector = '[data-component="prompt-input"]' -export const terminalSelector = '[data-component="terminal"]' -export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' - export function createSdk(directory?: string) { return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true }) } diff --git a/packages/app/package.json b/packages/app/package.json index 8459172e7c..e1232cb1ce 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.45", + "version": "1.1.48", "description": "", "type": "module", "exports": { diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts index 2c7be2ad95..df2107f76d 100644 --- a/packages/app/script/e2e-local.ts +++ b/packages/app/script/e2e-local.ts @@ -58,7 +58,7 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-")) const serverEnv = { ...process.env, - OPENCODE_DISABLE_SHARE: "true", + OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true", OPENCODE_DISABLE_LSP_DOWNLOAD: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 449f2091bd..5b00f80c05 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -167,7 +167,7 @@ export function SessionHeader() { triggerAs={Button} triggerProps={{ variant: "secondary", - class: "rounded-sm w-[60px] h-[24px]", + class: "rounded-sm h-[24px] px-3", classList: { "rounded-r-none": shareUrl() !== undefined }, style: { scale: 1 }, }} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 3b08652bbb..b26f6ba229 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -148,6 +148,7 @@ export const SettingsGeneral: Component = () => { description={language.t("settings.general.row.language.description")} >